1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-23 16:13:21 +00:00

Merge branch 'main' of https://github.com/bitwarden/clients into vault/pm-27632/sdk-cipher-ops

This commit is contained in:
Nik Gilmore
2026-01-02 16:21:38 -08:00
39 changed files with 1252 additions and 479 deletions

View File

@@ -1,5 +1,5 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
@@ -37,7 +37,12 @@ import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
import {
BiometricStateService,
BiometricsService,
BiometricsStatus,
KeyService,
} from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
@@ -64,6 +69,7 @@ describe("AccountSecurityComponent", () => {
const apiService = mock<ApiService>();
const billingService = mock<BillingAccountProfileStateService>();
const biometricStateService = mock<BiometricStateService>();
const biometricsService = mock<BiometricsService>();
const configService = mock<ConfigService>();
const dialogService = mock<DialogService>();
const keyService = mock<KeyService>();
@@ -75,6 +81,7 @@ describe("AccountSecurityComponent", () => {
const validationService = mock<ValidationService>();
const vaultNudgesService = mock<NudgesService>();
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
const mockI18nService = mock<I18nService>();
// Mock subjects to control the phishing detection observables
let phishingAvailableSubject: BehaviorSubject<boolean>;
@@ -91,14 +98,14 @@ describe("AccountSecurityComponent", () => {
provide: BillingAccountProfileStateService,
useValue: billingService,
},
{ provide: BiometricsService, useValue: mock<BiometricsService>() },
{ provide: BiometricsService, useValue: biometricsService },
{ provide: BiometricStateService, useValue: biometricStateService },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ConfigService, useValue: configService },
{ provide: DialogService, useValue: dialogService },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: I18nService, useValue: mockI18nService },
{ provide: KeyService, useValue: keyService },
{ provide: LockService, useValue: lockService },
{ provide: LogService, useValue: mock<LogService>() },
@@ -153,6 +160,7 @@ describe("AccountSecurityComponent", () => {
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
configService.getFeatureFlag$.mockReturnValue(of(false));
billingService.hasPremiumPersonally$.mockReturnValue(of(true));
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
policyService.policiesByType$.mockReturnValue(of([null]));
@@ -459,4 +467,118 @@ describe("AccountSecurityComponent", () => {
});
});
});
describe("biometrics polling timer", () => {
let browserApiSpy: jest.SpyInstance;
beforeEach(() => {
browserApiSpy = jest.spyOn(BrowserApi, "permissionsGranted");
});
afterEach(() => {
component.ngOnDestroy();
});
it("disables biometric control when canEnableBiometricUnlock is false", fakeAsync(async () => {
biometricsService.canEnableBiometricUnlock.mockResolvedValue(false);
await component.ngOnInit();
tick();
expect(component.form.controls.biometric.disabled).toBe(true);
}));
it("enables biometric control when canEnableBiometricUnlock is true", fakeAsync(async () => {
biometricsService.canEnableBiometricUnlock.mockResolvedValue(true);
await component.ngOnInit();
tick();
expect(component.form.controls.biometric.disabled).toBe(false);
}));
it("skips status check when nativeMessaging permission is not granted and not Safari", fakeAsync(async () => {
biometricsService.canEnableBiometricUnlock.mockResolvedValue(true);
browserApiSpy.mockResolvedValue(false);
platformUtilsService.isSafari.mockReturnValue(false);
await component.ngOnInit();
tick();
expect(biometricsService.getBiometricsStatusForUser).not.toHaveBeenCalled();
expect(component.biometricUnavailabilityReason).toBeUndefined();
}));
it("checks biometrics status when nativeMessaging permission is granted", fakeAsync(async () => {
biometricsService.canEnableBiometricUnlock.mockResolvedValue(true);
browserApiSpy.mockResolvedValue(true);
platformUtilsService.isSafari.mockReturnValue(false);
biometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.DesktopDisconnected,
);
await component.ngOnInit();
tick();
expect(biometricsService.getBiometricsStatusForUser).toHaveBeenCalledWith(mockUserId);
}));
it("should check status on Safari", fakeAsync(async () => {
biometricsService.canEnableBiometricUnlock.mockResolvedValue(true);
browserApiSpy.mockResolvedValue(false);
platformUtilsService.isSafari.mockReturnValue(true);
biometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.DesktopDisconnected,
);
await component.ngOnInit();
tick();
expect(biometricsService.getBiometricsStatusForUser).toHaveBeenCalledWith(mockUserId);
}));
test.each([
[
BiometricsStatus.DesktopDisconnected,
"biometricsStatusHelptextDesktopDisconnected-used-i18n",
],
[
BiometricsStatus.NotEnabledInConnectedDesktopApp,
"biometricsStatusHelptextNotEnabledInDesktop-used-i18n",
],
[
BiometricsStatus.HardwareUnavailable,
"biometricsStatusHelptextHardwareUnavailable-used-i18n",
],
])(
"sets expected unavailability reason for %s status when biometric not available",
fakeAsync(async (biometricStatus: BiometricsStatus, expected: string) => {
biometricsService.canEnableBiometricUnlock.mockResolvedValue(false);
browserApiSpy.mockResolvedValue(true);
platformUtilsService.isSafari.mockReturnValue(false);
biometricsService.getBiometricsStatusForUser.mockResolvedValue(biometricStatus);
await component.ngOnInit();
tick();
expect(component.biometricUnavailabilityReason).toBe(expected);
}),
);
it("should not set unavailability reason for error statuses when biometric is available", fakeAsync(async () => {
biometricsService.canEnableBiometricUnlock.mockResolvedValue(true);
browserApiSpy.mockResolvedValue(true);
platformUtilsService.isSafari.mockReturnValue(false);
biometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.DesktopDisconnected,
);
await component.ngOnInit();
tick();
// Status is DesktopDisconnected but biometric IS available, so don't show error
expect(component.biometricUnavailabilityReason).toBe("");
component.ngOnDestroy();
}));
});
});

View File

@@ -149,6 +149,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
protected refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
private destroy$ = new Subject<void>();
private readonly BIOMETRICS_POLLING_INTERVAL = 2000;
constructor(
private accountService: AccountService,
@@ -264,10 +265,9 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
};
this.form.patchValue(initialValues, { emitEvent: false });
timer(0, 1000)
timer(0, this.BIOMETRICS_POLLING_INTERVAL)
.pipe(
switchMap(async () => {
const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id);
const biometricSettingAvailable = await this.biometricsService.canEnableBiometricUnlock();
if (!biometricSettingAvailable) {
this.form.controls.biometric.disable({ emitEvent: false });
@@ -275,6 +275,15 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
this.form.controls.biometric.enable({ emitEvent: false });
}
// Biometrics status shouldn't be checked if permissions are needed.
const needsPermissionPrompt =
!(await BrowserApi.permissionsGranted(["nativeMessaging"])) &&
!this.platformUtilsService.isSafari();
if (needsPermissionPrompt) {
return;
}
const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id);
if (status === BiometricsStatus.DesktopDisconnected && !biometricSettingAvailable) {
this.biometricUnavailabilityReason = this.i18nService.t(
"biometricsStatusHelptextDesktopDisconnected",

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
@@ -15,14 +13,17 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
private readonly setElementStyles = setElementStyles;
private readonly sendExtensionMessage = sendExtensionMessage;
private port: chrome.runtime.Port | null = null;
private portKey: string;
private portKey?: string;
private readonly extensionOrigin: string;
private iframeMutationObserver: MutationObserver;
private iframe: HTMLIFrameElement;
private ariaAlertElement: HTMLDivElement;
private ariaAlertTimeout: number | NodeJS.Timeout;
private delayedCloseTimeout: number | NodeJS.Timeout;
private fadeInTimeout: number | NodeJS.Timeout;
/**
* Initialized in initMenuIframe which makes it safe to assert non null by lifecycle.
*/
private iframe!: HTMLIFrameElement;
private ariaAlertElement?: HTMLDivElement;
private ariaAlertTimeout: number | NodeJS.Timeout | null = null;
private delayedCloseTimeout: number | NodeJS.Timeout | null = null;
private fadeInTimeout: number | NodeJS.Timeout | null = null;
private readonly fadeInOpacityTransition = "opacity 125ms ease-out 0s";
private readonly fadeOutOpacityTransition = "opacity 65ms ease-out 0s";
private iframeStyles: Partial<CSSStyleDeclaration> = {
@@ -50,7 +51,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
};
private foreignMutationsCount = 0;
private mutationObserverIterations = 0;
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout | null = null;
private readonly backgroundPortMessageHandlers: BackgroundPortMessageHandlers = {
initAutofillInlineMenuButton: ({ message }) => this.initAutofillInlineMenu(message),
initAutofillInlineMenuList: ({ message }) => this.initAutofillInlineMenu(message),
@@ -134,7 +135,9 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.port.onDisconnect.addListener(this.handlePortDisconnect);
this.port.onMessage.addListener(this.handlePortMessage);
this.announceAriaAlert(this.ariaAlert, 2000);
if (this.ariaAlert) {
this.announceAriaAlert(this.ariaAlert, 2000);
}
};
/**
@@ -155,7 +158,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.ariaAlertTimeout = globalThis.setTimeout(async () => {
const isFieldFocused = await this.sendExtensionMessage("checkIsFieldCurrentlyFocused");
if (isFieldFocused || triggeredByUser) {
if ((isFieldFocused || triggeredByUser) && this.ariaAlertElement) {
this.shadow.appendChild(this.ariaAlertElement);
}
this.ariaAlertTimeout = null;
@@ -242,7 +245,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
*/
private initAutofillInlineMenuList(message: AutofillInlineMenuIframeExtensionMessage) {
const { theme } = message;
let borderColor: string;
let borderColor: string | undefined;
let verifiedTheme = theme;
if (verifiedTheme === ThemeTypes.System) {
verifiedTheme = globalThis.matchMedia("(prefers-color-scheme: dark)").matches
@@ -274,8 +277,8 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
*
* @param position - The position styles to apply to the iframe
*/
private updateIframePosition(position: Partial<CSSStyleDeclaration>) {
if (!globalThis.document.hasFocus()) {
private updateIframePosition(position?: Partial<CSSStyleDeclaration>) {
if (!position || !globalThis.document.hasFocus()) {
return;
}
@@ -295,7 +298,9 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
this.handleFadeInInlineMenuIframe();
}
this.announceAriaAlert(this.ariaAlert, 2000);
if (this.ariaAlert) {
this.announceAriaAlert(this.ariaAlert, 2000);
}
}
/**
@@ -359,8 +364,8 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
* @param customElement - The element to update the styles for
* @param styles - The styles to apply to the element
*/
private updateElementStyles(customElement: HTMLElement, styles: Partial<CSSStyleDeclaration>) {
if (!customElement) {
private updateElementStyles(customElement: HTMLElement, styles?: Partial<CSSStyleDeclaration>) {
if (!customElement || !styles) {
return;
}

View File

@@ -6,16 +6,18 @@
</popup-header>
<div class="tw-bg-background-alt">
<div *ngIf="!defaultBrowserAutofillDisabled && (showSpotlightNudge$ | async)" class="tw-mb-6">
<bit-spotlight
[title]="'autofillSpotlightTitle' | i18n"
[subtitle]="'autofillSpotlightDesc' | i18n"
[buttonText]="spotlightButtonText"
(onDismiss)="dismissSpotlight()"
(onButtonClick)="disableBrowserAutofillSettingsFromNudge($event)"
[buttonIcon]="spotlightButtonIcon"
></bit-spotlight>
</div>
@if (showSpotlightNudge$ | async) {
<div class="tw-mb-6">
<bit-spotlight
[title]="'autofillSpotlightTitle' | i18n"
[subtitle]="'autofillSpotlightDesc' | i18n"
[buttonText]="spotlightButtonText"
(onDismiss)="dismissSpotlight()"
(onButtonClick)="disableBrowserAutofillSettingsFromNudge($event)"
[buttonIcon]="spotlightButtonIcon"
></bit-spotlight>
</div>
}
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "autofillSuggestionsSectionTitle" | i18n }}</h2>

View File

@@ -611,6 +611,10 @@ export class AutofillComponent implements OnInit {
if (this.canOverrideBrowserAutofillSetting) {
this.defaultBrowserAutofillDisabled = true;
await this.updateDefaultBrowserAutofillDisabled();
await this.nudgesService.dismissNudge(
NudgeType.AutofillNudge,
await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)),
);
} else {
await this.openURI(event, this.disablePasswordManagerURI);
}

View File

@@ -60,6 +60,15 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
"button",
"image",
"file",
"search",
"url",
"date",
"time",
"datetime", // Note: datetime is deprecated in HTML5; keeping here for backwards compatibility
"datetime-local",
"week",
"color",
"range",
]);
constructor(

View File

@@ -126,6 +126,7 @@ import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/co
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -164,6 +165,7 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r
import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory";
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory";
import { DefaultRegisterSdkService } from "@bitwarden/common/platform/services/sdk/register-sdk.service";
import { SystemService } from "@bitwarden/common/platform/services/system.service";
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { PrimarySecondaryStorageService } from "@bitwarden/common/platform/storage/primary-secondary-storage.service";
@@ -463,6 +465,7 @@ export default class MainBackground {
themeStateService: DefaultThemeStateService;
autoSubmitLoginBackground: AutoSubmitLoginBackground;
sdkService: SdkService;
registerSdkService: RegisterSdkService;
sdkLoadService: SdkLoadService;
cipherAuthorizationService: CipherAuthorizationService;
endUserNotificationService: EndUserNotificationService;
@@ -578,7 +581,7 @@ export default class MainBackground {
"ephemeral",
"bitwarden-ephemeral",
);
await sessionStorage.save("session-key", derivedKey);
await sessionStorage.save("session-key", derivedKey.toJSON());
return derivedKey;
});
@@ -797,18 +800,6 @@ export default class MainBackground {
this.apiService,
this.accountService,
);
this.keyConnectorService = new KeyConnectorService(
this.accountService,
this.masterPasswordService,
this.keyService,
this.apiService,
this.tokenService,
this.logService,
this.organizationService,
this.keyGenerationService,
logoutCallback,
this.stateProvider,
);
this.authService = new AuthService(
this.accountService,
@@ -846,6 +837,37 @@ export default class MainBackground {
this.configService,
);
this.registerSdkService = new DefaultRegisterSdkService(
sdkClientFactory,
this.environmentService,
this.platformUtilsService,
this.accountService,
this.apiService,
this.stateProvider,
this.configService,
);
this.accountCryptographicStateService = new DefaultAccountCryptographicStateService(
this.stateProvider,
);
this.keyConnectorService = new KeyConnectorService(
this.accountService,
this.masterPasswordService,
this.keyService,
this.apiService,
this.tokenService,
this.logService,
this.organizationService,
this.keyGenerationService,
logoutCallback,
this.stateProvider,
this.configService,
this.registerSdkService,
this.securityStateService,
this.accountCryptographicStateService,
);
this.pinService = new PinService(
this.encryptService,
this.logService,
@@ -1014,9 +1036,7 @@ export default class MainBackground {
this.avatarService = new AvatarService(this.apiService, this.stateProvider);
this.providerService = new ProviderService(this.stateProvider);
this.accountCryptographicStateService = new DefaultAccountCryptographicStateService(
this.stateProvider,
);
this.syncService = new DefaultSyncService(
this.masterPasswordService,
this.accountService,

View File

@@ -0,0 +1,98 @@
export type PhishingResource = {
name?: string;
remoteUrl: string;
checksumUrl: string;
todayUrl: string;
/** Matcher used to decide whether a given URL matches an entry from this resource */
match: (url: URL, entry: string) => boolean;
};
export const PhishingResourceType = Object.freeze({
Domains: "domains",
Links: "links",
} as const);
export type PhishingResourceType = (typeof PhishingResourceType)[keyof typeof PhishingResourceType];
export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]> = {
[PhishingResourceType.Domains]: [
{
name: "Phishing.Database Domains",
remoteUrl:
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt",
checksumUrl:
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5",
todayUrl:
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-domains-NEW-today.txt",
match: (url: URL, entry: string) => {
if (!entry) {
return false;
}
const candidate = entry.trim().toLowerCase().replace(/\/$/, "");
// If entry contains a scheme, strip it for comparison
const e = candidate.replace(/^https?:\/\//, "");
// Compare against hostname or host+path
if (e === url.hostname.toLowerCase()) {
return true;
}
const urlNoProto = url.href
.toLowerCase()
.replace(/https?:\/\//, "")
.replace(/\/$/, "");
return urlNoProto === e || urlNoProto.startsWith(e + "/");
},
},
],
[PhishingResourceType.Links]: [
{
name: "Phishing.Database Links",
remoteUrl:
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-links-ACTIVE.txt",
checksumUrl:
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-links-ACTIVE.txt.md5",
todayUrl:
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-links-NEW-today.txt",
match: (url: URL, entry: string) => {
if (!entry) {
return false;
}
// Basic HTML entity decode for common cases (the lists sometimes contain &amp;)
const decodeHtml = (s: string) => s.replace(/&amp;/g, "&");
const normalizedEntry = decodeHtml(entry.trim()).toLowerCase().replace(/\/$/, "");
// Normalize URL for comparison - always strip protocol for consistent matching
const normalizedUrl = decodeHtml(url.href).toLowerCase().replace(/\/$/, "");
const urlNoProto = normalizedUrl.replace(/^https?:\/\//, "");
// Strip protocol from entry if present (http:// and https:// should be treated as equivalent)
const entryNoProto = normalizedEntry.replace(/^https?:\/\//, "");
// Compare full path (without protocol) - exact match
if (urlNoProto === entryNoProto) {
return true;
}
// Check if URL starts with entry (prefix match for subpaths/query/hash)
// e.g., entry "site.com/phish" matches "site.com/phish/subpage" or "site.com/phish?id=1"
if (
urlNoProto.startsWith(entryNoProto + "/") ||
urlNoProto.startsWith(entryNoProto + "?") ||
urlNoProto.startsWith(entryNoProto + "#")
) {
return true;
}
return false;
},
},
],
};
export function getPhishingResources(
type: PhishingResourceType,
index = 0,
): PhishingResource | undefined {
const list = PHISHING_RESOURCES[type] ?? [];
return list[index];
}

View File

@@ -25,7 +25,7 @@ describe("PhishingDataService", () => {
};
let fetchChecksumSpy: jest.SpyInstance;
let fetchDomainsSpy: jest.SpyInstance;
let fetchWebAddressesSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
@@ -45,113 +45,113 @@ describe("PhishingDataService", () => {
platformUtilsService,
);
fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingDomainsChecksum");
fetchDomainsSpy = jest.spyOn(service as any, "fetchPhishingDomains");
fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum");
fetchWebAddressesSpy = jest.spyOn(service as any, "fetchPhishingWebAddresses");
});
describe("isPhishingDomains", () => {
it("should detect a phishing domain", async () => {
describe("isPhishingWebAddress", () => {
it("should detect a phishing web address", async () => {
setMockState({
domains: ["phish.com", "badguy.net"],
webAddresses: ["phish.com", "badguy.net"],
timestamp: Date.now(),
checksum: "abc123",
applicationVersion: "1.0.0",
});
const url = new URL("http://phish.com");
const result = await service.isPhishingDomain(url);
const result = await service.isPhishingWebAddress(url);
expect(result).toBe(true);
});
it("should not detect a safe domain", async () => {
it("should not detect a safe web address", async () => {
setMockState({
domains: ["phish.com", "badguy.net"],
webAddresses: ["phish.com", "badguy.net"],
timestamp: Date.now(),
checksum: "abc123",
applicationVersion: "1.0.0",
});
const url = new URL("http://safe.com");
const result = await service.isPhishingDomain(url);
const result = await service.isPhishingWebAddress(url);
expect(result).toBe(false);
});
it("should match against root domain", async () => {
it("should match against root web address", async () => {
setMockState({
domains: ["phish.com", "badguy.net"],
webAddresses: ["phish.com", "badguy.net"],
timestamp: Date.now(),
checksum: "abc123",
applicationVersion: "1.0.0",
});
const url = new URL("http://phish.com/about");
const result = await service.isPhishingDomain(url);
const result = await service.isPhishingWebAddress(url);
expect(result).toBe(true);
});
it("should not error on empty state", async () => {
setMockState(undefined as any);
const url = new URL("http://phish.com/about");
const result = await service.isPhishingDomain(url);
const result = await service.isPhishingWebAddress(url);
expect(result).toBe(false);
});
});
describe("getNextDomains", () => {
it("refetches all domains if applicationVersion has changed", async () => {
describe("getNextWebAddresses", () => {
it("refetches all web addresses if applicationVersion has changed", async () => {
const prev: PhishingData = {
domains: ["a.com"],
webAddresses: ["a.com"],
timestamp: Date.now() - 60000,
checksum: "old",
applicationVersion: "1.0.0",
};
fetchChecksumSpy.mockResolvedValue("new");
fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]);
fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]);
platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0");
const result = await service.getNextDomains(prev);
const result = await service.getNextWebAddresses(prev);
expect(result!.domains).toEqual(["d.com", "e.com"]);
expect(result!.webAddresses).toEqual(["d.com", "e.com"]);
expect(result!.checksum).toBe("new");
expect(result!.applicationVersion).toBe("2.0.0");
});
it("only updates timestamp if checksum matches", async () => {
const prev: PhishingData = {
domains: ["a.com"],
webAddresses: ["a.com"],
timestamp: Date.now() - 60000,
checksum: "abc",
applicationVersion: "1.0.0",
};
fetchChecksumSpy.mockResolvedValue("abc");
const result = await service.getNextDomains(prev);
expect(result!.domains).toEqual(prev.domains);
const result = await service.getNextWebAddresses(prev);
expect(result!.webAddresses).toEqual(prev.webAddresses);
expect(result!.checksum).toBe("abc");
expect(result!.timestamp).not.toBe(prev.timestamp);
});
it("patches daily domains if cache is fresh", async () => {
const prev: PhishingData = {
domains: ["a.com"],
webAddresses: ["a.com"],
timestamp: Date.now() - 60000,
checksum: "old",
applicationVersion: "1.0.0",
};
fetchChecksumSpy.mockResolvedValue("new");
fetchDomainsSpy.mockResolvedValue(["b.com", "c.com"]);
const result = await service.getNextDomains(prev);
expect(result!.domains).toEqual(["a.com", "b.com", "c.com"]);
fetchWebAddressesSpy.mockResolvedValue(["b.com", "c.com"]);
const result = await service.getNextWebAddresses(prev);
expect(result!.webAddresses).toEqual(["a.com", "b.com", "c.com"]);
expect(result!.checksum).toBe("new");
});
it("fetches all domains if cache is old", async () => {
const prev: PhishingData = {
domains: ["a.com"],
webAddresses: ["a.com"],
timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000,
checksum: "old",
applicationVersion: "1.0.0",
};
fetchChecksumSpy.mockResolvedValue("new");
fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]);
const result = await service.getNextDomains(prev);
expect(result!.domains).toEqual(["d.com", "e.com"]);
fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]);
const result = await service.getNextWebAddresses(prev);
expect(result!.webAddresses).toEqual(["d.com", "e.com"]);
expect(result!.checksum).toBe("new");
});
});

View File

@@ -20,14 +20,16 @@ import { ScheduledTaskNames, TaskSchedulerService } from "@bitwarden/common/plat
import { LogService } from "@bitwarden/logging";
import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bitwarden/state";
import { getPhishingResources, PhishingResourceType } from "../phishing-resources";
export type PhishingData = {
domains: string[];
webAddresses: string[];
timestamp: number;
checksum: string;
/**
* We store the application version to refetch the entire dataset on a new client release.
* This counteracts daily appends updates not removing inactive or false positive domains.
* This counteracts daily appends updates not removing inactive or false positive web addresses.
*/
applicationVersion: string;
};
@@ -37,34 +39,27 @@ export const PHISHING_DOMAINS_KEY = new KeyDefinition<PhishingData>(
"phishingDomains",
{
deserializer: (value: PhishingData) =>
value ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" },
value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" },
},
);
/** Coordinates fetching, caching, and patching of known phishing domains */
/** Coordinates fetching, caching, and patching of known phishing web addresses */
export class PhishingDataService {
private static readonly RemotePhishingDatabaseUrl =
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt";
private static readonly RemotePhishingDatabaseChecksumUrl =
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5";
private static readonly RemotePhishingDatabaseTodayUrl =
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-domains-NEW-today.txt";
private _testDomains = this.getTestDomains();
private _testWebAddresses = this.getTestWebAddresses();
private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY);
private _domains$ = this._cachedState.state$.pipe(
private _webAddresses$ = this._cachedState.state$.pipe(
map(
(state) =>
new Set(
(state?.domains?.filter((line) => line.trim().length > 0) ?? []).concat(
this._testDomains,
(state?.webAddresses?.filter((line) => line.trim().length > 0) ?? []).concat(
this._testWebAddresses,
"phishing.testcategory.com", // Included for QA to test in prod
),
),
),
);
// How often are new domains added to the remote?
// How often are new web addresses added to the remote?
readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours
private _triggerUpdate$ = new Subject<void>();
@@ -75,7 +70,7 @@ export class PhishingDataService {
this._cachedState.state$.pipe(
first(), // Only take the first value to avoid an infinite loop when updating the cache below
switchMap(async (cachedState) => {
const next = await this.getNextDomains(cachedState);
const next = await this.getNextWebAddresses(cachedState);
if (next) {
await this._cachedState.update(() => next);
this.logService.info(`[PhishingDataService] cache updated`);
@@ -85,7 +80,7 @@ export class PhishingDataService {
count: 3,
delay: (err, count) => {
this.logService.error(
`[PhishingDataService] Unable to update domains. Attempt ${count}.`,
`[PhishingDataService] Unable to update web addresses. Attempt ${count}.`,
err,
);
return timer(5 * 60 * 1000); // 5 minutes
@@ -97,7 +92,7 @@ export class PhishingDataService {
err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */,
) => {
this.logService.error(
"[PhishingDataService] Retries unsuccessful. Unable to update domains.",
"[PhishingDataService] Retries unsuccessful. Unable to update web addresses.",
err,
);
return EMPTY;
@@ -114,6 +109,7 @@ export class PhishingDataService {
private globalStateProvider: GlobalStateProvider,
private logService: LogService,
private platformUtilsService: PlatformUtilsService,
private resourceType: PhishingResourceType = PhishingResourceType.Links,
) {
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => {
this._triggerUpdate$.next();
@@ -125,22 +121,31 @@ export class PhishingDataService {
}
/**
* Checks if the given URL is a known phishing domain
* Checks if the given URL is a known phishing web address
*
* @param url The URL to check
* @returns True if the URL is a known phishing domain, false otherwise
* @returns True if the URL is a known phishing web address, false otherwise
*/
async isPhishingDomain(url: URL): Promise<boolean> {
const domains = await firstValueFrom(this._domains$);
const result = domains.has(url.hostname);
if (result) {
return true;
async isPhishingWebAddress(url: URL): Promise<boolean> {
// Use domain (hostname) matching for domain resources, and link matching for links resources
const entries = await firstValueFrom(this._webAddresses$);
const resource = getPhishingResources(this.resourceType);
if (resource && resource.match) {
for (const entry of entries) {
if (resource.match(url, entry)) {
return true;
}
}
return false;
}
return false;
// Default/domain behavior: exact hostname match as a fallback
return entries.has(url.hostname);
}
async getNextDomains(prev: PhishingData | null): Promise<PhishingData | null> {
prev = prev ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" };
async getNextWebAddresses(prev: PhishingData | null): Promise<PhishingData | null> {
prev = prev ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" };
const timestamp = Date.now();
const prevAge = timestamp - prev.timestamp;
this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`);
@@ -148,7 +153,7 @@ export class PhishingDataService {
const applicationVersion = await this.platformUtilsService.getApplicationVersion();
// If checksum matches, return existing data with new timestamp & version
const remoteChecksum = await this.fetchPhishingDomainsChecksum();
const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType);
if (remoteChecksum && prev.checksum === remoteChecksum) {
this.logService.info(
`[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`,
@@ -157,66 +162,66 @@ export class PhishingDataService {
}
// Checksum is different, data needs to be updated.
// Approach 1: Fetch only new domains and append
// Approach 1: Fetch only new web addresses and append
const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION;
if (isOneDayOldMax && applicationVersion === prev.applicationVersion) {
const dailyDomains: string[] = await this.fetchPhishingDomains(
PhishingDataService.RemotePhishingDatabaseTodayUrl,
);
const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl;
const dailyWebAddresses: string[] =
await this.fetchPhishingWebAddresses(webAddressesTodayUrl);
this.logService.info(
`[PhishingDataService] ${dailyDomains.length} new phishing domains added`,
`[PhishingDataService] ${dailyWebAddresses.length} new phishing web addresses added`,
);
return {
domains: prev.domains.concat(dailyDomains),
webAddresses: prev.webAddresses.concat(dailyWebAddresses),
checksum: remoteChecksum,
timestamp,
applicationVersion,
};
}
// Approach 2: Fetch all domains
const domains = await this.fetchPhishingDomains(PhishingDataService.RemotePhishingDatabaseUrl);
// Approach 2: Fetch all web addresses
const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl;
const remoteWebAddresses = await this.fetchPhishingWebAddresses(remoteUrl);
return {
domains,
webAddresses: remoteWebAddresses,
timestamp,
checksum: remoteChecksum,
applicationVersion,
};
}
private async fetchPhishingDomainsChecksum() {
const response = await this.apiService.nativeFetch(
new Request(PhishingDataService.RemotePhishingDatabaseChecksumUrl),
);
private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) {
const checksumUrl = getPhishingResources(type)!.checksumUrl;
const response = await this.apiService.nativeFetch(new Request(checksumUrl));
if (!response.ok) {
throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`);
}
return response.text();
}
private async fetchPhishingDomains(url: string) {
private async fetchPhishingWebAddresses(url: string) {
const response = await this.apiService.nativeFetch(new Request(url));
if (!response.ok) {
throw new Error(`[PhishingDataService] Failed to fetch domains: ${response.status}`);
throw new Error(`[PhishingDataService] Failed to fetch web addresses: ${response.status}`);
}
return response.text().then((text) => text.split("\n"));
}
private getTestDomains() {
private getTestWebAddresses() {
const flag = devFlagEnabled("testPhishingUrls");
if (!flag) {
return [];
}
const domains = devFlagValue("testPhishingUrls") as unknown[];
if (domains && domains instanceof Array) {
const webAddresses = devFlagValue("testPhishingUrls") as unknown[];
if (webAddresses && webAddresses instanceof Array) {
this.logService.debug(
"[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:",
domains,
"[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:",
webAddresses,
);
return domains as string[];
return webAddresses as string[];
}
return [];
}

View File

@@ -94,7 +94,7 @@ export class PhishingDetectionService {
this._ignoredHostnames.delete(url.hostname);
return;
}
const isPhishing = await phishingDataService.isPhishingDomain(url);
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
if (!isPhishing) {
return;
}

View File

@@ -23,6 +23,8 @@ import {
WINDOW,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { AUTOFILL_NUDGE_SERVICE } from "@bitwarden/angular/vault";
import { SingleNudgeService } from "@bitwarden/angular/vault/services/default-single-nudge.service";
import {
LoginComponentService,
TwoFactorAuthComponentService,
@@ -208,6 +210,7 @@ import {
} from "../../platform/system-notifications/browser-system-notification.service";
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
import { BrowserAutofillNudgeService } from "../../vault/popup/services/browser-autofill-nudge.service";
import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service";
import { ExtensionAnonLayoutWrapperDataService } from "../components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service";
@@ -756,6 +759,11 @@ const safeProviders: SafeProvider[] = [
MessagingServiceAbstraction,
],
}),
safeProvider({
provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken<SingleNudgeService>,
useClass: BrowserAutofillNudgeService,
deps: [],
}),
];
@NgModule({

View File

@@ -34,13 +34,11 @@
<i slot="start" class="bwi bwi-check-circle" aria-hidden="true"></i>
<div class="tw-flex tw-items-center tw-justify-center">
<p class="tw-pr-2">{{ "autofill" | i18n }}</p>
<span
*ngIf="!(isBrowserAutofillSettingOverridden$ | async) && (showAutofillBadge$ | async)"
bitBadge
variant="notification"
[attr.aria-label]="'nudgeBadgeAria' | i18n"
>1</span
>
@if (showAutofillBadge$ | async) {
<span bitBadge variant="notification" [attr.aria-label]="'nudgeBadgeAria' | i18n"
>1</span
>
}
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>

View File

@@ -148,31 +148,7 @@ describe("SettingsV2Component", () => {
expect(openSpy).toHaveBeenCalledWith(dialogService);
});
it("isBrowserAutofillSettingOverridden$ emits the value from the AutofillBrowserSettingsService", async () => {
pushActiveAccount();
mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(true);
const fixture = TestBed.createComponent(SettingsV2Component);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
const value = await firstValueFrom(component["isBrowserAutofillSettingOverridden$"]);
expect(value).toBe(true);
mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(false);
const fixture2 = TestBed.createComponent(SettingsV2Component);
const component2 = fixture2.componentInstance;
fixture2.detectChanges();
await fixture2.whenStable();
const value2 = await firstValueFrom(component2["isBrowserAutofillSettingOverridden$"]);
expect(value2).toBe(false);
});
it("showAutofillBadge$ emits true when default autofill is NOT disabled and nudge is true", async () => {
it("showAutofillBadge$ emits true when showNudgeBadge is true", async () => {
pushActiveAccount();
mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) =>
@@ -184,30 +160,10 @@ describe("SettingsV2Component", () => {
fixture.detectChanges();
await fixture.whenStable();
mockAutofillSettings.defaultBrowserAutofillDisabled$.next(false);
const value = await firstValueFrom(component.showAutofillBadge$);
expect(value).toBe(true);
});
it("showAutofillBadge$ emits false when default autofill IS disabled even if nudge is true", async () => {
pushActiveAccount();
mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) =>
of(type === NudgeType.AutofillNudge),
);
const fixture = TestBed.createComponent(SettingsV2Component);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
mockAutofillSettings.defaultBrowserAutofillDisabled$.next(true);
const value = await firstValueFrom(component.showAutofillBadge$);
expect(value).toBe(false);
});
it("dismissBadge dismisses when showVaultBadge$ emits true", async () => {
const acct = pushActiveAccount();

View File

@@ -1,16 +1,7 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import {
combineLatest,
filter,
firstValueFrom,
from,
map,
Observable,
shareReplay,
switchMap,
} from "rxjs";
import { filter, firstValueFrom, Observable, shareReplay, switchMap } from "rxjs";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -28,8 +19,6 @@ import {
} from "@bitwarden/components";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@@ -55,12 +44,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
export class SettingsV2Component {
NudgeType = NudgeType;
protected isBrowserAutofillSettingOverridden$ = from(
this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden(
BrowserApi.getBrowserClientVendor(window),
),
);
private authenticatedAccount$: Observable<Account> = this.accountService.activeAccount$.pipe(
filter((account): account is Account => account !== null),
shareReplay({ bufferSize: 1, refCount: true }),
@@ -82,23 +65,13 @@ export class SettingsV2Component {
),
);
showAutofillBadge$: Observable<boolean> = combineLatest([
this.autofillBrowserSettingsService.defaultBrowserAutofillDisabled$,
this.authenticatedAccount$,
]).pipe(
switchMap(([defaultBrowserAutofillDisabled, account]) =>
this.nudgesService.showNudgeBadge$(NudgeType.AutofillNudge, account.id).pipe(
map((badgeStatus) => {
return !defaultBrowserAutofillDisabled && badgeStatus;
}),
),
),
showAutofillBadge$: Observable<boolean> = this.authenticatedAccount$.pipe(
switchMap((account) => this.nudgesService.showNudgeBadge$(NudgeType.AutofillNudge, account.id)),
);
constructor(
private readonly nudgesService: NudgesService,
private readonly accountService: AccountService,
private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService,
private readonly accountProfileStateService: BillingAccountProfileStateService,
private readonly dialogService: DialogService,
) {}

View File

@@ -0,0 +1,157 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { NudgeStatus, NudgeType } from "@bitwarden/angular/vault";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { BrowserClientVendors } from "@bitwarden/common/autofill/constants";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../../libs/common/spec";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { BrowserAutofillNudgeService } from "./browser-autofill-nudge.service";
describe("BrowserAutofillNudgeService", () => {
let service: BrowserAutofillNudgeService;
let vaultProfileService: MockProxy<VaultProfileService>;
let fakeStateProvider: FakeStateProvider;
const userId = "test-user-id" as UserId;
const nudgeType = NudgeType.AutofillNudge;
const notDismissedStatus: NudgeStatus = {
hasBadgeDismissed: false,
hasSpotlightDismissed: false,
};
const dismissedStatus: NudgeStatus = {
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
};
// Set profile creation date to now (new account, within 30 days)
const recentProfileDate = new Date();
beforeEach(() => {
vaultProfileService = mock<VaultProfileService>();
vaultProfileService.getProfileCreationDate.mockResolvedValue(recentProfileDate);
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
TestBed.configureTestingModule({
providers: [
BrowserAutofillNudgeService,
{
provide: VaultProfileService,
useValue: vaultProfileService,
},
{
provide: StateProvider,
useValue: fakeStateProvider,
},
{
provide: LogService,
useValue: mock<LogService>(),
},
],
});
service = TestBed.inject(BrowserAutofillNudgeService);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("nudgeStatus$", () => {
it("returns parent status when browser client is Unknown", async () => {
jest
.spyOn(BrowserApi, "getBrowserClientVendor")
.mockReturnValue(BrowserClientVendors.Unknown);
jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(true);
const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId));
expect(result).toEqual(notDismissedStatus);
});
it("returns parent status when browser autofill is not overridden", async () => {
jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue(BrowserClientVendors.Chrome);
jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(false);
const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId));
expect(result).toEqual(notDismissedStatus);
});
it("returns dismissed status when browser autofill is overridden", async () => {
jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue(BrowserClientVendors.Chrome);
jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(true);
const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId));
expect(result).toEqual(dismissedStatus);
});
it("preserves parent dismissed status when account is older than 30 days", async () => {
// Set profile creation date to more than 30 days ago
const oldProfileDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
vaultProfileService.getProfileCreationDate.mockResolvedValue(oldProfileDate);
jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue(BrowserClientVendors.Chrome);
jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(false);
const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId));
expect(result).toEqual(dismissedStatus);
});
it("combines parent dismissed and browser autofill overridden status", async () => {
// Set profile creation date to more than 30 days ago (parent dismisses)
const oldProfileDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
vaultProfileService.getProfileCreationDate.mockResolvedValue(oldProfileDate);
jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue(BrowserClientVendors.Chrome);
jest.spyOn(BrowserApi, "browserAutofillSettingsOverridden").mockResolvedValue(true);
const result = await firstValueFrom(service.nudgeStatus$(nudgeType, userId));
expect(result).toEqual(dismissedStatus);
});
it.each([
BrowserClientVendors.Chrome,
BrowserClientVendors.Edge,
BrowserClientVendors.Opera,
BrowserClientVendors.Vivaldi,
])("checks browser autofill settings for %s browser", async (browserVendor) => {
const getBrowserClientVendorSpy = jest
.spyOn(BrowserApi, "getBrowserClientVendor")
.mockReturnValue(browserVendor);
const browserAutofillSettingsOverriddenSpy = jest
.spyOn(BrowserApi, "browserAutofillSettingsOverridden")
.mockResolvedValue(true);
await firstValueFrom(service.nudgeStatus$(nudgeType, userId));
expect(getBrowserClientVendorSpy).toHaveBeenCalledWith(window);
expect(browserAutofillSettingsOverriddenSpy).toHaveBeenCalled();
});
it("does not check browser autofill settings for Unknown browser", async () => {
jest
.spyOn(BrowserApi, "getBrowserClientVendor")
.mockReturnValue(BrowserClientVendors.Unknown);
const browserAutofillSettingsOverriddenSpy = jest
.spyOn(BrowserApi, "browserAutofillSettingsOverridden")
.mockResolvedValue(true);
await firstValueFrom(service.nudgeStatus$(nudgeType, userId));
expect(browserAutofillSettingsOverriddenSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,37 @@
import { Injectable } from "@angular/core";
import { Observable, switchMap } from "rxjs";
import { NudgeStatus, NudgeType } from "@bitwarden/angular/vault";
import { NewAccountNudgeService } from "@bitwarden/angular/vault/services/custom-nudges-services/new-account-nudge.service";
import { BrowserClientVendors } from "@bitwarden/common/autofill/constants";
import { UserId } from "@bitwarden/common/types/guid";
import { BrowserApi } from "../../../platform/browser/browser-api";
/**
* Browser-specific autofill nudge service.
* Extends NewAccountNudgeService (30-day account age check) and adds
* browser autofill setting detection.
*
* Nudge is dismissed if:
* - Account is older than 30 days (inherited from NewAccountNudgeService)
* - Browser's built-in password manager is already disabled via privacy settings
*/
@Injectable()
export class BrowserAutofillNudgeService extends NewAccountNudgeService {
override nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return super.nudgeStatus$(nudgeType, userId).pipe(
switchMap(async (status) => {
const browserClient = BrowserApi.getBrowserClientVendor(window);
const browserAutofillOverridden =
browserClient !== BrowserClientVendors.Unknown &&
(await BrowserApi.browserAutofillSettingsOverridden());
return {
hasBadgeDismissed: status.hasBadgeDismissed || browserAutofillOverridden,
hasSpotlightDismissed: status.hasSpotlightDismissed || browserAutofillOverridden,
};
}),
);
}
}