diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 76503845fd..a4137ca359 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -38,6 +38,7 @@ libs/importer @bitwarden/team-tools-dev
libs/tools @bitwarden/team-tools-dev
## Dirt (Data Insights & Reporting) team files ##
+apps/browser/src/dirt @bitwarden/team-data-insights-and-reporting-dev
apps/web/src/app/dirt @bitwarden/team-data-insights-and-reporting-dev
bitwarden_license/bit-common/src/dirt @bitwarden/team-data-insights-and-reporting-dev
bitwarden_license/bit-web/src/app/dirt @bitwarden/team-data-insights-and-reporting-dev
diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 9b4066a848..eb8732d7d9 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -5511,6 +5511,18 @@
"hasItemsVaultNudgeTitle": {
"message": "Welcome to your vault!"
},
+ "phishingPageTitle":{
+ "message": "Phishing website"
+ },
+ "phishingPageCloseTab": {
+ "message": "Close tab"
+ },
+ "phishingPageContinue": {
+ "message": "Continue"
+ },
+ "phishingPageLearnWhy": {
+ "message": "Why are you seeing this?"
+ },
"hasItemsVaultNudgeBodyOne": {
"message": "Autofill items for the current page"
},
diff --git a/apps/browser/src/auth/popup/change-password/extension-change-password.service.spec.ts b/apps/browser/src/auth/popup/change-password/extension-change-password.service.spec.ts
index a6a6b90521..4dc12e57d2 100644
--- a/apps/browser/src/auth/popup/change-password/extension-change-password.service.spec.ts
+++ b/apps/browser/src/auth/popup/change-password/extension-change-password.service.spec.ts
@@ -1,12 +1,12 @@
import { MockProxy, mock } from "jest-mock-extended";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
+import BrowserPopupUtils from "@bitwarden/browser/platform/browser/browser-popup-utils";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
-import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { ExtensionChangePasswordService } from "./extension-change-password.service";
diff --git a/apps/browser/src/auth/popup/change-password/extension-change-password.service.ts b/apps/browser/src/auth/popup/change-password/extension-change-password.service.ts
index dd2ce48d27..0838e841d3 100644
--- a/apps/browser/src/auth/popup/change-password/extension-change-password.service.ts
+++ b/apps/browser/src/auth/popup/change-password/extension-change-password.service.ts
@@ -2,12 +2,12 @@ import {
DefaultChangePasswordService,
ChangePasswordService,
} from "@bitwarden/angular/auth/password-management/change-password";
+import BrowserPopupUtils from "@bitwarden/browser/platform/browser/browser-popup-utils";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
-import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
export class ExtensionChangePasswordService
extends DefaultChangePasswordService
diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index 96178cc6dd..792b8a8b41 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -286,6 +286,7 @@ import { AutofillBadgeUpdaterService } from "../autofill/services/autofill-badge
import AutofillService from "../autofill/services/autofill.service";
import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service";
import { SafariApp } from "../browser/safariApp";
+import { PhishingDetectionService } from "../dirt/phishing-detection/services/phishing-detection.service";
import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service";
import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service";
import { BrowserActionsService } from "../platform/actions/browser-actions.service";
@@ -1402,6 +1403,15 @@ export default class MainBackground {
this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
+ PhishingDetectionService.initialize(
+ this.configService,
+ this.auditService,
+ this.logService,
+ this.storageService,
+ this.taskSchedulerService,
+ this.eventCollectionService,
+ );
+
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);
this.ipcService = new IpcBackgroundService(this.platformUtilsService, this.logService);
diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html b/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html
new file mode 100644
index 0000000000..5ea79c3f84
--- /dev/null
+++ b/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html
@@ -0,0 +1,4 @@
+{{ "phishingPageLearnWhy"| i18n}}
+
+ {{ "learnMore" | i18n }}
+
diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts b/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts
new file mode 100644
index 0000000000..1a1e605920
--- /dev/null
+++ b/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts
@@ -0,0 +1,16 @@
+// eslint-disable-next-line no-restricted-imports
+import { CommonModule } from "@angular/common";
+// eslint-disable-next-line no-restricted-imports
+import { Component } from "@angular/core";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { ButtonModule } from "@bitwarden/components";
+
+@Component({
+ standalone: true,
+ templateUrl: "learn-more-component.html",
+ imports: [CommonModule, CommonModule, JslibModule, ButtonModule],
+})
+export class LearnMoreComponent {
+ constructor() {}
+}
diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html
new file mode 100644
index 0000000000..f6e3baf876
--- /dev/null
+++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html
@@ -0,0 +1,13 @@
+
+
+ {{ "phishingPageTitle" | i18n }}
+
+
+
+
+
+
diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts
new file mode 100644
index 0000000000..dc6ab2d329
--- /dev/null
+++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts
@@ -0,0 +1,58 @@
+// eslint-disable-next-line no-restricted-imports
+import { CommonModule } from "@angular/common";
+// eslint-disable-next-line no-restricted-imports
+import { Component, OnDestroy } from "@angular/core";
+// eslint-disable-next-line no-restricted-imports
+import { ActivatedRoute, RouterModule } from "@angular/router";
+import { Subject, takeUntil } from "rxjs";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import {
+ AsyncActionsModule,
+ ButtonModule,
+ CheckboxModule,
+ FormFieldModule,
+ IconModule,
+ LinkModule,
+} from "@bitwarden/components";
+
+import { PhishingDetectionService } from "../services/phishing-detection.service";
+
+@Component({
+ standalone: true,
+ templateUrl: "phishing-warning.component.html",
+ imports: [
+ CommonModule,
+ IconModule,
+ JslibModule,
+ LinkModule,
+ FormFieldModule,
+ AsyncActionsModule,
+ CheckboxModule,
+ ButtonModule,
+ RouterModule,
+ ],
+})
+export class PhishingWarning implements OnDestroy {
+ phishingHost = "";
+
+ private destroy$ = new Subject();
+
+ constructor(private activatedRoute: ActivatedRoute) {
+ this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ this.phishingHost = params.get("phishingHost") || "";
+ });
+ }
+
+ async closeTab() {
+ await PhishingDetectionService.requestClosePhishingWarningPage();
+ }
+ async continueAnyway() {
+ await PhishingDetectionService.requestContinueToDangerousUrl();
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+}
diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts
new file mode 100644
index 0000000000..8d3c3ec5b3
--- /dev/null
+++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts
@@ -0,0 +1,51 @@
+import { of } from "rxjs";
+
+import { AuditService } from "@bitwarden/common/abstractions/audit.service";
+import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
+import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service";
+
+import { PhishingDetectionService } from "./phishing-detection.service";
+
+describe("PhishingDetectionService", () => {
+ let auditService: AuditService;
+ let logService: LogService;
+ let storageService: AbstractStorageService;
+ let taskSchedulerService: TaskSchedulerService;
+ let configService: ConfigService;
+ let eventCollectionService: EventCollectionService;
+
+ beforeEach(() => {
+ auditService = { getKnownPhishingDomains: jest.fn() } as any;
+ logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any;
+ storageService = { get: jest.fn(), save: jest.fn() } as any;
+ taskSchedulerService = { registerTaskHandler: jest.fn(), setInterval: jest.fn() } as any;
+ configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any;
+ eventCollectionService = {} as any;
+ });
+
+ it("should initialize without errors", () => {
+ expect(() => {
+ PhishingDetectionService.initialize(
+ configService,
+ auditService,
+ logService,
+ storageService,
+ taskSchedulerService,
+ eventCollectionService,
+ );
+ }).not.toThrow();
+ });
+
+ it("should detect phishing domains", () => {
+ PhishingDetectionService["_knownPhishingDomains"].add("phishing.com");
+ const url = new URL("https://phishing.com");
+ expect(PhishingDetectionService.isPhishingDomain(url)).toBe(true);
+ const safeUrl = new URL("https://safe.com");
+ expect(PhishingDetectionService.isPhishingDomain(safeUrl)).toBe(false);
+ });
+
+ // Add more tests for other methods as needed
+});
diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts
new file mode 100644
index 0000000000..dd7cf083a0
--- /dev/null
+++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts
@@ -0,0 +1,685 @@
+import { concatMap, delay, Subject, Subscription } from "rxjs";
+
+import { AuditService } from "@bitwarden/common/abstractions/audit.service";
+import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
+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 { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
+import { devFlagEnabled, devFlagValue } from "@bitwarden/common/platform/misc/flags";
+import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
+import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service";
+
+import { BrowserApi } from "../../../platform/browser/browser-api";
+
+import {
+ CaughtPhishingDomain,
+ isPhishingDetectionMessage,
+ PhishingDetectionMessage,
+ PhishingDetectionNavigationEvent,
+ PhishingDetectionTabId,
+} from "./phishing-detection.types";
+
+export class PhishingDetectionService {
+ private static readonly _UPDATE_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
+ private static readonly _RETRY_INTERVAL = 5 * 60 * 1000; // 5 minutes
+ private static readonly _MAX_RETRIES = 3;
+ private static readonly _STORAGE_KEY = "phishing_domains_cache";
+ private static _auditService: AuditService;
+ private static _logService: LogService;
+ private static _storageService: AbstractStorageService;
+ private static _taskSchedulerService: TaskSchedulerService;
+ private static _updateCacheSubscription: Subscription | null = null;
+ private static _retrySubscription: Subscription | null = null;
+ private static _navigationEventsSubject = new Subject();
+ private static _navigationEvents: Subscription | null = null;
+ private static _knownPhishingDomains = new Set();
+ private static _caughtTabs: Map = new Map();
+ private static _isInitialized = false;
+ private static _isUpdating = false;
+ private static _retryCount = 0;
+ private static _lastUpdateTime: number = 0;
+
+ static initialize(
+ configService: ConfigService,
+ auditService: AuditService,
+ logService: LogService,
+ storageService: AbstractStorageService,
+ taskSchedulerService: TaskSchedulerService,
+ eventCollectionService: EventCollectionService,
+ ): void {
+ this._auditService = auditService;
+ this._logService = logService;
+ this._storageService = storageService;
+ this._taskSchedulerService = taskSchedulerService;
+
+ logService.info("[PhishingDetectionService] Initialize called");
+
+ configService
+ .getFeatureFlag$(FeatureFlag.PhishingDetection)
+ .pipe(
+ concatMap(async (enabled) => {
+ if (!enabled) {
+ logService.info(
+ "[PhishingDetectionService] Phishing detection feature flag is disabled.",
+ );
+ this._cleanup();
+ } else {
+ // Enable phishing detection service
+ logService.info("[PhishingDetectionService] Enabling phishing detection service");
+ await this._setup();
+ }
+ }),
+ )
+ .subscribe();
+ }
+
+ /**
+ * Checks if the given URL is a known phishing domain
+ *
+ * @param url The URL to check
+ * @returns True if the URL is a known phishing domain, false otherwise
+ */
+ static isPhishingDomain(url: URL): boolean {
+ const result = this._knownPhishingDomains.has(url.hostname);
+ if (result) {
+ this._logService.debug("[PhishingDetectionService] Caught phishing domain:", url.hostname);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Sends a message to the phishing detection service to close the warning page
+ */
+ static requestClosePhishingWarningPage(): void {
+ void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
+ }
+
+ /**
+ * Sends a message to the phishing detection service to continue to the caught url
+ */
+ static async requestContinueToDangerousUrl() {
+ void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue });
+ }
+
+ /**
+ * 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 {
+ 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");
+ }
+ }
+
+ /**
+ * Initializes the phishing detection service, setting up listeners and registering tasks
+ */
+ private static async _setup(): Promise {
+ if (this._isInitialized) {
+ this._logService.info("[PhishingDetectionService] Already initialized, skipping setup.");
+ return;
+ }
+
+ this._isInitialized = true;
+ this._setupListeners();
+
+ // Register the update task
+ this._taskSchedulerService.registerTaskHandler(
+ ScheduledTaskNames.phishingDomainUpdate,
+ async () => {
+ try {
+ await this._fetchKnownPhishingDomains();
+ } catch (error) {
+ this._logService.error(
+ "[PhishingDetectionService] Failed to update phishing domains in task handler:",
+ error,
+ );
+ }
+ },
+ );
+
+ // Initial load of cached domains
+ await this._loadCachedDomains();
+
+ // Set up periodic updates every 24 hours
+ this._setupPeriodicUpdates();
+ this._logService.debug("[PhishingDetectionService] Phishing detection feature is initialized.");
+ }
+
+ /**
+ * Sets up listeners for messages from the web page and web navigation events
+ */
+ private static _setupListeners(): void {
+ // 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._navigationEvents = this._navigationEventsSubject
+ .pipe(
+ delay(100), // Delay slightly to allow replace events to be caught
+ )
+ .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.TabChangeInfo,
+ tab: chrome.tabs.Tab,
+ ): Promise {
+ 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
+ this._checkTabForPhishing(tabId, new URL(tab.url));
+ await this._handleTabNavigation(tabId);
+ }
+
+ private static _handleNavigationEvent(
+ tabId: number,
+ changeInfo: chrome.tabs.TabChangeInfo,
+ tab: chrome.tabs.Tab,
+ ): boolean {
+ this._navigationEventsSubject.next({ tabId, changeInfo, tab });
+
+ // Return value for supporting BrowserApi event listener signature
+ return true;
+ }
+
+ /**
+ * Handles a replace event in Safari when redirecting to a warning page
+ *
+ * @returns true if the replacement was handled, false otherwise
+ */
+ private static _handleReplacementEvent(newTabId: number, originalTabId: number): boolean {
+ if (this._caughtTabs.has(originalTabId)) {
+ 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 _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) {
+ // Check if the tab already being tracked
+ const caughtTab = this._caughtTabs.get(tabId);
+
+ const isPhishing = this.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");
+ }
+ }
+
+ /**
+ * Sets up periodic updates for phishing domains
+ */
+ private static _setupPeriodicUpdates() {
+ // Clean up any existing subscriptions
+ if (this._updateCacheSubscription) {
+ this._updateCacheSubscription.unsubscribe();
+ }
+ if (this._retrySubscription) {
+ this._retrySubscription.unsubscribe();
+ }
+
+ this._updateCacheSubscription = this._taskSchedulerService.setInterval(
+ ScheduledTaskNames.phishingDomainUpdate,
+ this._UPDATE_INTERVAL,
+ );
+ }
+
+ /**
+ * Schedules a retry for updating phishing domains if the update fails
+ */
+ private static _scheduleRetry() {
+ // If we've exceeded max retries, stop retrying
+ if (this._retryCount >= this._MAX_RETRIES) {
+ this._logService.warning(
+ `[PhishingDetectionService] Max retries (${this._MAX_RETRIES}) reached for phishing domain update. Will try again in ${this._UPDATE_INTERVAL / (1000 * 60 * 60)} hours.`,
+ );
+ this._retryCount = 0;
+ if (this._retrySubscription) {
+ this._retrySubscription.unsubscribe();
+ this._retrySubscription = null;
+ }
+ return;
+ }
+
+ // Clean up existing retry subscription if any
+ if (this._retrySubscription) {
+ this._retrySubscription.unsubscribe();
+ }
+
+ // Increment retry count
+ this._retryCount++;
+
+ // Schedule a retry in 5 minutes
+ this._retrySubscription = this._taskSchedulerService.setInterval(
+ ScheduledTaskNames.phishingDomainUpdate,
+ this._RETRY_INTERVAL,
+ );
+
+ this._logService.info(
+ `[PhishingDetectionService] Scheduled retry ${this._retryCount}/${this._MAX_RETRIES} for phishing domain update in ${this._RETRY_INTERVAL / (1000 * 60)} minutes`,
+ );
+ }
+
+ /**
+ * Handles adding test phishing URLs from dev flags for testing purposes
+ */
+ private static _handleTestUrls() {
+ if (devFlagEnabled("testPhishingUrls")) {
+ const testPhishingUrls = devFlagValue("testPhishingUrls");
+ this._logService.debug(
+ "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:",
+ testPhishingUrls,
+ );
+ if (testPhishingUrls && testPhishingUrls instanceof Array) {
+ testPhishingUrls.forEach((domain) => {
+ if (domain && typeof domain === "string") {
+ this._knownPhishingDomains.add(domain);
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Loads cached phishing domains from storage
+ * If no cache exists or it is expired, fetches the latest domains
+ */
+ private static async _loadCachedDomains() {
+ try {
+ const cachedData = await this._storageService.get<{ domains: string[]; timestamp: number }>(
+ this._STORAGE_KEY,
+ );
+ if (cachedData) {
+ this._logService.info("[PhishingDetectionService] Phishing cachedData exists");
+ const phishingDomains = cachedData.domains || [];
+
+ this._setKnownPhishingDomains(phishingDomains);
+ this._handleTestUrls();
+ }
+
+ // If cache is empty or expired, trigger an immediate update
+ if (
+ this._knownPhishingDomains.size === 0 ||
+ Date.now() - this._lastUpdateTime >= this._UPDATE_INTERVAL
+ ) {
+ await this._fetchKnownPhishingDomains();
+ }
+ } catch (error) {
+ this._logService.error(
+ "[PhishingDetectionService] Failed to load cached phishing domains:",
+ error,
+ );
+ this._handleTestUrls();
+ }
+ }
+
+ /**
+ * Fetches the latest known phishing domains from the audit service
+ * Updates the cache and handles retries if necessary
+ */
+ static async _fetchKnownPhishingDomains(): Promise {
+ let domains: string[] = [];
+
+ // Prevent concurrent updates
+ if (this._isUpdating) {
+ this._logService.warning(
+ "[PhishingDetectionService] Update already in progress, skipping...",
+ );
+ return;
+ }
+
+ try {
+ this._logService.info("[PhishingDetectionService] Starting phishing domains update...");
+ this._isUpdating = true;
+ domains = await this._auditService.getKnownPhishingDomains();
+ this._setKnownPhishingDomains(domains);
+
+ await this._saveDomains();
+
+ this._resetRetry();
+ this._isUpdating = false;
+
+ this._logService.info("[PhishingDetectionService] Successfully fetched domains");
+ } catch (error) {
+ this._logService.error(
+ "[PhishingDetectionService] Failed to fetch known phishing domains.",
+ error,
+ );
+
+ this._scheduleRetry();
+ this._isUpdating = false;
+
+ throw error;
+ }
+ }
+
+ /**
+ * Saves the known phishing domains to storage
+ * Caches the updated domains and updates the last update time
+ */
+ private static async _saveDomains() {
+ try {
+ // Cache the updated domains
+ await this._storageService.save(this._STORAGE_KEY, {
+ domains: Array.from(this._knownPhishingDomains),
+ timestamp: this._lastUpdateTime,
+ });
+ this._logService.info(
+ `[PhishingDetectionService] Updated phishing domains cache with ${this._knownPhishingDomains.size} domains`,
+ );
+ } catch (error) {
+ this._logService.error(
+ "[PhishingDetectionService] Failed to save known phishing domains.",
+ error,
+ );
+ this._scheduleRetry();
+ throw error;
+ }
+ }
+
+ /**
+ * Resets the retry count and clears the retry subscription
+ */
+ private static _resetRetry(): void {
+ this._logService.info(
+ `[PhishingDetectionService] Resetting retry count and clearing retry subscription.`,
+ );
+ // Reset retry count and clear retry subscription on success
+ this._retryCount = 0;
+ if (this._retrySubscription) {
+ this._retrySubscription.unsubscribe();
+ this._retrySubscription = null;
+ }
+ }
+
+ /**
+ * Adds phishing domains to the known phishing domains set
+ * Clears old domains to prevent memory leaks
+ *
+ * @param domains Array of phishing domains to add
+ */
+ private static _setKnownPhishingDomains(domains: string[]): void {
+ this._logService.debug(
+ `[PhishingDetectionService] Tracking ${domains.length} phishing domains`,
+ );
+
+ // Clear old domains to prevent memory leaks
+ this._knownPhishingDomains.clear();
+
+ domains.forEach((domain: string) => {
+ if (domain) {
+ this._knownPhishingDomains.add(domain);
+ }
+ });
+ this._lastUpdateTime = Date.now();
+ }
+
+ /**
+ * Cleans up the phishing detection service
+ * Unsubscribes from all subscriptions and clears caches
+ */
+ private static _cleanup() {
+ if (this._updateCacheSubscription) {
+ this._updateCacheSubscription.unsubscribe();
+ this._updateCacheSubscription = null;
+ }
+ if (this._retrySubscription) {
+ this._retrySubscription.unsubscribe();
+ this._retrySubscription = null;
+ }
+ if (this._navigationEvents) {
+ this._navigationEvents.unsubscribe();
+ this._navigationEvents = null;
+ }
+ this._knownPhishingDomains.clear();
+ this._caughtTabs.clear();
+ this._lastUpdateTime = 0;
+ this._isUpdating = false;
+ this._isInitialized = false;
+ this._retryCount = 0;
+
+ // 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,
+ );
+ }
+}
diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts
new file mode 100644
index 0000000000..86fe61909c
--- /dev/null
+++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts
@@ -0,0 +1,35 @@
+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)["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.TabChangeInfo;
+ tab: chrome.tabs.Tab;
+};
diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts
index 60a42078cf..339fd71b07 100644
--- a/apps/browser/src/platform/browser/browser-api.ts
+++ b/apps/browser/src/platform/browser/browser-api.ts
@@ -212,6 +212,47 @@ export class BrowserApi {
);
}
+ /**
+ * Closes a browser tab with the given id
+ *
+ * @param tabId The id of the tab to close
+ */
+ static async closeTab(tabId: number): Promise {
+ if (tabId) {
+ if (BrowserApi.isWebExtensionsApi) {
+ browser.tabs.remove(tabId).catch((error) => {
+ throw new Error("[BrowserApi] Failed to remove current tab: " + error.message);
+ });
+ } else if (BrowserApi.isChromeApi) {
+ chrome.tabs.remove(tabId).catch((error) => {
+ throw new Error("[BrowserApi] Failed to remove current tab: " + error.message);
+ });
+ }
+ }
+ }
+
+ /**
+ * Navigates a browser tab to the given URL
+ *
+ * @param tabId The id of the tab to navigate
+ * @param url The URL to navigate to
+ */
+ static async navigateTabToUrl(tabId: number, url: URL): Promise {
+ if (tabId) {
+ if (BrowserApi.isWebExtensionsApi) {
+ browser.tabs.update(tabId, { url: url.href }).catch((error) => {
+ throw new Error("Failed to navigate tab to URL: " + error.message);
+ });
+ } else if (BrowserApi.isChromeApi) {
+ chrome.tabs.update(tabId, { url: url.href }, () => {
+ if (chrome.runtime.lastError) {
+ throw new Error("Failed to navigate tab to URL: " + chrome.runtime.lastError.message);
+ }
+ });
+ }
+ }
+ }
+
static async tabsQuery(options: chrome.tabs.QueryInfo): Promise {
return new Promise((resolve) => {
chrome.tabs.query(options, (tabs) => {
@@ -233,7 +274,7 @@ export class BrowserApi {
* Drop-in replacement for {@link BrowserApi.tabsQueryFirst}.
*
* Safari sometimes returns >1 tabs unexpectedly even when
- * specificing a `windowId` or `currentWindow: true` query option.
+ * specifying a `windowId` or `currentWindow: true` query option.
*
* For all of these calls,
* ```
@@ -320,6 +361,14 @@ export class BrowserApi {
chrome.tabs.sendMessage(tabId, message, options, responseCallback);
}
+ static getRuntimeURL(path: string): string {
+ if (BrowserApi.isWebExtensionsApi) {
+ return browser.runtime.getURL(path);
+ } else if (BrowserApi.isChromeApi) {
+ return chrome.runtime.getURL(path);
+ }
+ }
+
static async onWindowCreated(callback: (win: chrome.windows.Window) => any) {
// FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener
// and test that it doesn't break.
diff --git a/apps/browser/src/platform/browser/browser-popup-utils.ts b/apps/browser/src/platform/browser/browser-popup-utils.ts
index e9fe3dd1ea..aebb3e9211 100644
--- a/apps/browser/src/platform/browser/browser-popup-utils.ts
+++ b/apps/browser/src/platform/browser/browser-popup-utils.ts
@@ -17,7 +17,7 @@ export const PopupWidthOptions = Object.freeze({
type PopupWidthOptions = typeof PopupWidthOptions;
export type PopupWidthOption = keyof PopupWidthOptions;
-class BrowserPopupUtils {
+export default class BrowserPopupUtils {
/**
* Identifies if the popup is within the sidebar.
*
@@ -288,5 +288,3 @@ class BrowserPopupUtils {
return parsedUrl.toString();
}
}
-
-export default BrowserPopupUtils;
diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts
index dd1d1574b2..251a86ef80 100644
--- a/apps/browser/src/popup/app-routing.module.ts
+++ b/apps/browser/src/popup/app-routing.module.ts
@@ -23,6 +23,7 @@ import {
UserLockIcon,
VaultIcon,
LockIcon,
+ DeactivatedOrg,
} from "@bitwarden/assets/svg";
import {
LoginComponent,
@@ -40,7 +41,7 @@ import {
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
-import { AnonLayoutWrapperData } from "@bitwarden/components";
+import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
@@ -53,6 +54,8 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component";
+import { LearnMoreComponent } from "../dirt/phishing-detection/pages/learn-more-component";
+import { PhishingWarning } from "../dirt/phishing-detection/pages/phishing-warning.component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
@@ -672,6 +675,32 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 2 } satisfies RouteDataProperties,
},
+ {
+ path: "security",
+ component: AnonLayoutWrapperComponent,
+ children: [
+ {
+ path: "phishing-warning",
+ children: [
+ {
+ path: "",
+ component: PhishingWarning,
+ },
+ {
+ path: "",
+ component: LearnMoreComponent,
+ outlet: "secondary",
+ },
+ ],
+ data: {
+ pageIcon: DeactivatedOrg,
+ pageTitle: "Bitwarden blocked it!",
+ pageSubtitle: "Bitwarden blocked a known phishing site from loading.",
+ showReadonlyHostname: true,
+ } satisfies AnonLayoutWrapperData,
+ },
+ ],
+ },
];
@Injectable()
diff --git a/libs/common/src/abstractions/audit.service.ts b/libs/common/src/abstractions/audit.service.ts
index b019ebe1fe..a871df506d 100644
--- a/libs/common/src/abstractions/audit.service.ts
+++ b/libs/common/src/abstractions/audit.service.ts
@@ -14,4 +14,10 @@ export abstract class AuditService {
* @returns A promise that resolves to an array of BreachAccountResponse objects.
*/
abstract breachedAccounts: (username: string) => Promise;
+ /**
+ * Checks if a domain is known for phishing.
+ * @param domain The domain to check.
+ * @returns A promise that resolves to a boolean indicating if the domain is known for phishing.
+ */
+ abstract getKnownPhishingDomains: () => Promise;
}
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index 90c6669e5b..0471fa7316 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -41,6 +41,7 @@ export enum FeatureFlag {
/* DIRT */
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
+ PhishingDetection = "phishing-detection",
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
/* Vault */
@@ -84,6 +85,7 @@ export const DefaultFeatureFlagValue = {
/* DIRT */
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,
+ [FeatureFlag.PhishingDetection]: FALSE,
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
/* Vault */
diff --git a/libs/common/src/platform/misc/flags.ts b/libs/common/src/platform/misc/flags.ts
index 30531b6799..5aabd1c59d 100644
--- a/libs/common/src/platform/misc/flags.ts
+++ b/libs/common/src/platform/misc/flags.ts
@@ -12,6 +12,7 @@ export type SharedDevFlags = {
skipWelcomeOnInstall: boolean;
configRetrievalIntervalMs: number;
showRiskInsightsDebug: boolean;
+ testPhishingUrls: string[];
};
function getFlags(envFlags: string | T): T {
diff --git a/libs/common/src/platform/scheduling/scheduled-task-name.enum.ts b/libs/common/src/platform/scheduling/scheduled-task-name.enum.ts
index 990a4c77c9..7cc9687374 100644
--- a/libs/common/src/platform/scheduling/scheduled-task-name.enum.ts
+++ b/libs/common/src/platform/scheduling/scheduled-task-name.enum.ts
@@ -8,6 +8,7 @@ export const ScheduledTaskNames = {
eventUploadsInterval: "eventUploadsInterval",
vaultTimeoutCheckInterval: "vaultTimeoutCheckInterval",
clearPopupViewCache: "clearPopupViewCache",
+ phishingDomainUpdate: "phishingDomainUpdate",
} as const;
export type ScheduledTaskName = (typeof ScheduledTaskNames)[keyof typeof ScheduledTaskNames];
diff --git a/libs/common/src/services/audit.service.ts b/libs/common/src/services/audit.service.ts
index d1eddbbdf8..4d06b50d6b 100644
--- a/libs/common/src/services/audit.service.ts
+++ b/libs/common/src/services/audit.service.ts
@@ -78,4 +78,9 @@ export class AuditService implements AuditServiceAbstraction {
throw new Error();
}
}
+
+ async getKnownPhishingDomains(): Promise {
+ const response = await this.apiService.send("GET", "/phishing-domains", null, true, true);
+ return response as string[];
+ }
}