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[]; + } }