From 15faf52f57a0e0f8e0cb2b48e91216f6b1045907 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Mon, 6 Jan 2025 17:10:34 -0500 Subject: [PATCH] [PM-13115] Allow users to disable extension content script injections by domain (#11826) * add disabledInteractionsUris state to the domain settings service * add routes and ui for user disabledInteractionsUris state management * use disabled URIs service state as a preemptive conditon to injecting content scripts * move disabled domains navigation button from account security settings to autofill settings * update disabled domain terminology to blocked domain terminology * update copy * handle blocked domains initializing with null value * add dismissable banner to the vault view when the active autofill tab is on the blocked domains list * add autofill blocked domain indicators to autofill suggestions section header * add BlockBrowserInjectionsByDomain feature flag and put feature behind it * update router config to new style * update tests and cleanup * use full-width-notice slot for domain script injection blocked banner * convert thrown error on content script injection block to a warning and early return * simplify and enspeeden state resolution for blockedInteractionsUris * refactor feature flag state fetching and update tests * document domain settings service * remove vault component presentational updates --- apps/browser/src/_locales/en/messages.json | 15 ++ .../background/overlay.background.spec.ts | 8 +- .../overlay.background.deprecated.spec.ts | 6 +- .../popup/settings/autofill-v1.component.html | 12 + .../popup/settings/autofill-v1.component.ts | 5 + .../popup/settings/autofill.component.html | 6 + .../popup/settings/autofill.component.ts | 7 +- .../settings/blocked-domains.component.html | 66 ++++++ .../settings/blocked-domains.component.ts | 208 ++++++++++++++++++ .../settings/excluded-domains.component.ts | 37 ++-- .../services/autofill.service.spec.ts | 13 +- .../browser/src/background/main.background.ts | 7 +- .../browser-script-injector.service.spec.ts | 102 ++++++++- .../browser-script-injector.service.ts | 28 +++ apps/browser/src/popup/app-routing.module.ts | 7 + .../src/popup/services/services.module.ts | 4 +- .../fileless-importer.background.spec.ts | 34 +-- .../service-container/service-container.ts | 43 ++-- .../src/services/jslib-services.module.ts | 10 +- .../services/domain-settings.service.spec.ts | 9 +- .../services/domain-settings.service.ts | 69 +++++- libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/models/domain/domain-service.ts | 2 +- 23 files changed, 623 insertions(+), 77 deletions(-) create mode 100644 apps/browser/src/autofill/popup/settings/blocked-domains.component.html create mode 100644 apps/browser/src/autofill/popup/settings/blocked-domains.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 06f11406b68..85937b63304 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2324,6 +2324,9 @@ "message": "Domains", "description": "A category title describing the concept of web domains" }, + "blockedDomains": { + "message": "Blocked domains" + }, "excludedDomains": { "message": "Excluded domains" }, @@ -2333,6 +2336,15 @@ "excludedDomainsDescAlt": { "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." }, + "blockedDomainsDesc": { + "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." + }, + "autofillBlockedNotice": { + "message": "Autofill is blocked for this website. Review or change this in settings." + }, + "autofillBlockedTooltip": { + "message": "Autofill is blocked on this website. Review in settings." + }, "websiteItemLabel": { "message": "Website $number$ (URI)", "placeholders": { @@ -2351,6 +2363,9 @@ } } }, + "blockedDomainsSavedSuccess": { + "message": "Blocked domain changes saved" + }, "excludedDomainsSavedSuccess": { "message": "Excluded domain changes saved" }, diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index e7b72b72c9b..512a9ff4c2a 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1,5 +1,5 @@ import { mock, MockProxy, mockReset } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -14,6 +14,7 @@ import { } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService, Region, @@ -93,6 +94,7 @@ describe("OverlayBackground", () => { let logService: MockProxy; let cipherService: MockProxy; let autofillService: MockProxy; + let configService: MockProxy; let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; let environmentMock$: BehaviorSubject; @@ -149,11 +151,13 @@ describe("OverlayBackground", () => { } beforeEach(() => { + configService = mock(); + configService.getFeatureFlag$.mockImplementation(() => of(true)); accountService = mockAccountServiceWith(mockUserId); fakeStateProvider = new FakeStateProvider(accountService); showFaviconsMock$ = new BehaviorSubject(true); neverDomainsMock$ = new BehaviorSubject({}); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); domainSettingsService.showFavicons$ = showFaviconsMock$; domainSettingsService.neverDomains$ = neverDomainsMock$; logService = mock(); diff --git a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts index 3adaf9e276c..2c22097f3d0 100644 --- a/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts +++ b/apps/browser/src/autofill/deprecated/background/overlay.background.deprecated.spec.ts @@ -12,6 +12,7 @@ import { DefaultDomainSettingsService, DomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService, Region, @@ -61,6 +62,7 @@ describe("OverlayBackground", () => { let overlayBackground: LegacyOverlayBackground; const cipherService = mock(); const autofillService = mock(); + let configService: MockProxy; let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; @@ -92,7 +94,9 @@ describe("OverlayBackground", () => { }; beforeEach(() => { - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + configService = mock(); + configService.getFeatureFlag$.mockImplementation(() => of(true)); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; diff --git a/apps/browser/src/autofill/popup/settings/autofill-v1.component.html b/apps/browser/src/autofill/popup/settings/autofill-v1.component.html index 530519e88f1..1c16ee1fe12 100644 --- a/apps/browser/src/autofill/popup/settings/autofill-v1.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill-v1.component.html @@ -255,4 +255,16 @@

{{ "additionalOptions" | i18n }}

{{ "showIdentitiesCurrentTabDesc" | i18n }} +
+
+ +
+
diff --git a/apps/browser/src/autofill/popup/settings/autofill-v1.component.ts b/apps/browser/src/autofill/popup/settings/autofill-v1.component.ts index 085ccba7e1e..9f015d990e9 100644 --- a/apps/browser/src/autofill/popup/settings/autofill-v1.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill-v1.component.ts @@ -36,6 +36,7 @@ export class AutofillV1Component implements OnInit { protected autoFillOverlayVisibilityOptions: any[]; protected disablePasswordManagerLink: string; protected inlineMenuPositioningImprovementsEnabled: boolean = false; + protected blockBrowserInjectionsByDomainEnabled: boolean = false; protected showInlineMenuIdentities: boolean = true; protected showInlineMenuCards: boolean = true; inlineMenuIsEnabled: boolean = false; @@ -120,6 +121,10 @@ export class AutofillV1Component implements OnInit { FeatureFlag.InlineMenuPositioningImprovements, ); + this.blockBrowserInjectionsByDomainEnabled = await this.configService.getFeatureFlag( + FeatureFlag.BlockBrowserInjectionsByDomain, + ); + this.inlineMenuIsEnabled = this.isInlineMenuEnabled(); this.showInlineMenuIdentities = diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index e8882cf7bbb..eeae0a85e3f 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -282,5 +282,11 @@

{{ "additionalOptions" | i18n }}

+ + + {{ "blockedDomains" | i18n }} + + + diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index da997f550b3..884503fa360 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -49,7 +49,6 @@ import { import { BrowserApi } from "../../../platform/browser/browser-api"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; -import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -67,7 +66,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co JslibModule, LinkModule, PopOutComponent, - PopupFooterComponent, PopupHeaderComponent, PopupPageComponent, RouterModule, @@ -87,6 +85,7 @@ export class AutofillComponent implements OnInit { protected inlineMenuVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.OnFieldFocus; protected inlineMenuPositioningImprovementsEnabled: boolean = false; + protected blockBrowserInjectionsByDomainEnabled: boolean = false; protected browserClientVendor: BrowserClientVendor = BrowserClientVendors.Unknown; protected disablePasswordManagerURI: DisablePasswordManagerUri = DisablePasswordManagerUris.Unknown; @@ -164,6 +163,10 @@ export class AutofillComponent implements OnInit { FeatureFlag.InlineMenuPositioningImprovements, ); + this.blockBrowserInjectionsByDomainEnabled = await this.configService.getFeatureFlag( + FeatureFlag.BlockBrowserInjectionsByDomain, + ); + this.showInlineMenuIdentities = this.inlineMenuPositioningImprovementsEnabled && (await firstValueFrom(this.autofillSettingsService.showInlineMenuIdentities$)); diff --git a/apps/browser/src/autofill/popup/settings/blocked-domains.component.html b/apps/browser/src/autofill/popup/settings/blocked-domains.component.html new file mode 100644 index 00000000000..bf5f40f2b90 --- /dev/null +++ b/apps/browser/src/autofill/popup/settings/blocked-domains.component.html @@ -0,0 +1,66 @@ + + + + + + + +
+

{{ "blockedDomainsDesc" | i18n }}

+ + +

{{ "domainsTitle" | i18n }}

+ {{ blockedDomainsState?.length || 0 }} +
+ + + + + {{ + "websiteItemLabel" | i18n: i + 1 + }} + +
{{ domain }}
+
+ +
+
+ +
+
+ + + +
diff --git a/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts b/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts new file mode 100644 index 00000000000..461f62da6dc --- /dev/null +++ b/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts @@ -0,0 +1,208 @@ +import { CommonModule } from "@angular/common"; +import { + QueryList, + Component, + ElementRef, + OnDestroy, + AfterViewInit, + ViewChildren, +} from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { RouterModule } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + ButtonModule, + CardComponent, + FormFieldModule, + IconButtonModule, + ItemModule, + LinkModule, + SectionComponent, + SectionHeaderComponent, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; + +@Component({ + selector: "app-blocked-domains", + templateUrl: "blocked-domains.component.html", + standalone: true, + imports: [ + ButtonModule, + CardComponent, + CommonModule, + FormFieldModule, + FormsModule, + IconButtonModule, + ItemModule, + JslibModule, + LinkModule, + PopOutComponent, + PopupFooterComponent, + PopupHeaderComponent, + PopupPageComponent, + RouterModule, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + ], +}) +export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { + @ViewChildren("uriInput") uriInputElements: QueryList> = + new QueryList(); + + dataIsPristine = true; + isLoading = false; + blockedDomainsState: string[] = []; + storedBlockedDomains: string[] = []; + // How many fields should be non-editable before editable fields + fieldsEditThreshold: number = 0; + + private destroy$ = new Subject(); + + constructor( + private domainSettingsService: DomainSettingsService, + private i18nService: I18nService, + private toastService: ToastService, + ) {} + + async ngAfterViewInit() { + this.domainSettingsService.blockedInteractionsUris$ + .pipe(takeUntil(this.destroy$)) + .subscribe((neverDomains: NeverDomains) => this.handleStateUpdate(neverDomains)); + + this.uriInputElements.changes.pipe(takeUntil(this.destroy$)).subscribe(({ last }) => { + this.focusNewUriInput(last); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + handleStateUpdate(neverDomains: NeverDomains) { + if (neverDomains) { + this.storedBlockedDomains = Object.keys(neverDomains); + } + + this.blockedDomainsState = [...this.storedBlockedDomains]; + + // Do not allow the first x (pre-existing) fields to be edited + this.fieldsEditThreshold = this.storedBlockedDomains.length; + + this.dataIsPristine = true; + this.isLoading = false; + } + + focusNewUriInput(elementRef: ElementRef) { + if (elementRef?.nativeElement) { + elementRef.nativeElement.focus(); + } + } + + async addNewDomain() { + // add empty field to the Domains list interface + this.blockedDomainsState.push(""); + + await this.fieldChange(); + } + + async removeDomain(i: number) { + this.blockedDomainsState.splice(i, 1); + + // If a pre-existing field was dropped, lower the edit threshold + if (i < this.fieldsEditThreshold) { + this.fieldsEditThreshold--; + } + + await this.fieldChange(); + } + + async fieldChange() { + if (this.dataIsPristine) { + this.dataIsPristine = false; + } + } + + async saveChanges() { + if (this.dataIsPristine) { + return; + } + + this.isLoading = true; + + const newBlockedDomainsSaveState: NeverDomains = {}; + const uniqueBlockedDomains = new Set(this.blockedDomainsState); + + for (const uri of uniqueBlockedDomains) { + if (uri && uri !== "") { + const validatedHost = Utils.getHostname(uri); + + if (!validatedHost) { + this.toastService.showToast({ + message: this.i18nService.t("excludedDomainsInvalidDomain", uri), + title: "", + variant: "error", + }); + + // Don't reset via `handleStateUpdate` to allow existing input value correction + this.isLoading = false; + return; + } + + newBlockedDomainsSaveState[validatedHost] = null; + } + } + + try { + const existingState = new Set(this.storedBlockedDomains); + const newState = new Set(Object.keys(newBlockedDomainsSaveState)); + const stateIsUnchanged = + existingState.size === newState.size && + new Set([...existingState, ...newState]).size === existingState.size; + + // The subscriber updates don't trigger if `setNeverDomains` sets an equivalent state + if (stateIsUnchanged) { + // Reset UI state directly + const constructedNeverDomainsState = this.storedBlockedDomains.reduce( + (neverDomains: NeverDomains, uri: string) => ({ ...neverDomains, [uri]: null }), + {}, + ); + this.handleStateUpdate(constructedNeverDomainsState); + } else { + await this.domainSettingsService.setBlockedInteractionsUris(newBlockedDomainsSaveState); + } + + this.toastService.showToast({ + message: this.i18nService.t("blockedDomainsSavedSuccess"), + title: "", + variant: "success", + }); + } catch { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + title: "", + variant: "error", + }); + + // Don't reset via `handleStateUpdate` to preserve input values + this.isLoading = false; + } + } + + trackByFunction(index: number, _: string) { + return index; + } +} diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index 1391ad516fb..7d429bfe4f0 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { QueryList, @@ -17,7 +15,6 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ButtonModule, @@ -28,6 +25,7 @@ import { LinkModule, SectionComponent, SectionHeaderComponent, + ToastService, TypographyModule, } from "@bitwarden/components"; @@ -62,7 +60,8 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co ], }) export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { - @ViewChildren("uriInput") uriInputElements: QueryList>; + @ViewChildren("uriInput") uriInputElements: QueryList> = + new QueryList(); accountSwitcherEnabled = false; dataIsPristine = true; @@ -77,7 +76,7 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { constructor( private domainSettingsService: DomainSettingsService, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, ) { this.accountSwitcherEnabled = enableAccountSwitching(); } @@ -156,11 +155,11 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { const validatedHost = Utils.getHostname(uri); if (!validatedHost) { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("excludedDomainsInvalidDomain", uri), - ); + this.toastService.showToast({ + message: this.i18nService.t("excludedDomainsInvalidDomain", uri), + title: "", + variant: "error", + }); // Don't reset via `handleStateUpdate` to allow existing input value correction this.isLoading = false; @@ -182,7 +181,7 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { if (stateIsUnchanged) { // Reset UI state directly const constructedNeverDomainsState = this.storedExcludedDomains.reduce( - (neverDomains, uri) => ({ ...neverDomains, [uri]: null }), + (neverDomains: NeverDomains, uri: string) => ({ ...neverDomains, [uri]: null }), {}, ); this.handleStateUpdate(constructedNeverDomainsState); @@ -190,13 +189,17 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { await this.domainSettingsService.setNeverDomains(newExcludedDomainsSaveState); } - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("excludedDomainsSavedSuccess"), - ); + this.toastService.showToast({ + message: this.i18nService.t("excludedDomainsSavedSuccess"), + title: "", + variant: "success", + }); } catch { - this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError")); + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + title: "", + variant: "error", + }); // Don't reset via `handleStateUpdate` to preserve input values this.isLoading = false; diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 91f926440a0..77d73d7ae65 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -98,7 +98,13 @@ describe("AutofillService", () => { let messageListener: MockProxy; beforeEach(() => { - scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService); + configService = mock(); + configService.getFeatureFlag$.mockImplementation(() => of(false)); + scriptInjectorService = new BrowserScriptInjectorService( + domainSettingsService, + platformUtilsService, + logService, + ); inlineMenuVisibilityMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus); showInlineMenuCardsMock$ = new BehaviorSubject(false); showInlineMenuIdentitiesMock$ = new BehaviorSubject(false); @@ -106,10 +112,10 @@ describe("AutofillService", () => { autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilityMock$; autofillSettingsService.showInlineMenuCards$ = showInlineMenuCardsMock$; autofillSettingsService.showInlineMenuIdentities$ = showInlineMenuIdentitiesMock$; + autofillSettingsService.autofillOnPageLoad$ = of(true); activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; - configService = mock(); messageListener = mock(); enableChangedPasswordPromptMock$ = new BehaviorSubject(true); enableAddedLoginPromptMock$ = new BehaviorSubject(true); @@ -132,7 +138,7 @@ describe("AutofillService", () => { userNotificationsSettings, messageListener, ); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); jest.spyOn(BrowserApi, "tabSendMessage"); }); @@ -385,6 +391,7 @@ describe("AutofillService", () => { ); tabMock = createChromeTabMock(); sender = { tab: tabMock, frameId: 1 }; + jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock); jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); jest .spyOn(autofillService, "getInlineMenuVisibility") diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 555e3a13fa0..34c10508485 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -695,7 +695,6 @@ export default class MainBackground { this.vaultTimeoutSettingsService, ); - this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.fileUploadService = new FileUploadService(this.logService, this.apiService); this.cipherFileUploadService = new CipherFileUploadService( this.apiService, @@ -809,6 +808,11 @@ export default class MainBackground { this.authService, ); + this.domainSettingsService = new DefaultDomainSettingsService( + this.stateProvider, + this.configService, + ); + this.themeStateService = new DefaultThemeStateService( this.globalStateProvider, this.configService, @@ -957,6 +961,7 @@ export default class MainBackground { this.totpService = new TotpService(this.cryptoFunctionService, this.logService); this.scriptInjectorService = new BrowserScriptInjectorService( + this.domainSettingsService, this.platformUtilsService, this.logService, ); diff --git a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts index d6ec3dfde96..0919de46776 100644 --- a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts +++ b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts @@ -1,8 +1,22 @@ -import { mock } from "jest-mock-extended"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; +import { + DomainSettingsService, + DefaultDomainSettingsService, +} from "@bitwarden/common/autofill/services/domain-settings.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + FakeStateProvider, + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { createChromeTabMock } from "../../autofill/spec/autofill-mocks"; import { BrowserApi } from "../browser/browser-api"; import { @@ -11,8 +25,19 @@ import { } from "./abstractions/script-injector.service"; import { BrowserScriptInjectorService } from "./browser-script-injector.service"; +const mockEquivalentDomains = [ + ["example.com", "exampleapp.com", "example.co.uk", "ejemplo.es"], + ["bitwarden.com", "bitwarden.co.uk", "sm-bitwarden.com"], + ["example.co.uk", "exampleapp.co.uk"], +]; + describe("ScriptInjectorService", () => { const tabId = 1; + const tabMock = createChromeTabMock({ id: tabId }); + const mockBlockedURI = new URL(tabMock.url); + jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); + jest.spyOn(BrowserApi, "isManifestVersion"); + const combinedManifestVersionFile = "content/autofill-init.js"; const mv2SpecificFile = "content/autofill-init-mv2.js"; const mv2Details = { file: mv2SpecificFile }; @@ -22,14 +47,29 @@ describe("ScriptInjectorService", () => { runAt: "document_start", }; const manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get"); + let scriptInjectorService: BrowserScriptInjectorService; - jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); - jest.spyOn(BrowserApi, "isManifestVersion"); - const platformUtilsService = mock(); const logService = mock(); + const platformUtilsService = mock(); + const mockUserId = Utils.newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); + let configService: MockProxy; + let domainSettingsService: DomainSettingsService; beforeEach(() => { - scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService); + jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock); + configService = mock(); + configService.getFeatureFlag$.mockImplementation(() => of(false)); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); + domainSettingsService.blockedInteractionsUris$ = of(null); + scriptInjectorService = new BrowserScriptInjectorService( + domainSettingsService, + platformUtilsService, + logService, + ); + jest.spyOn(scriptInjectorService as any, "buildInjectionDetails"); }); describe("inject", () => { @@ -71,6 +111,58 @@ describe("ScriptInjectorService", () => { { world: "ISOLATED" }, ); }); + + it("skips injecting the script in manifest v3 when the tab domain is a blocked domain", async () => { + domainSettingsService.blockedInteractionsUris$ = of({ [mockBlockedURI.host]: null }); + manifestVersionSpy.mockReturnValue(3); + + await expect(scriptInjectorService["buildInjectionDetails"]).not.toHaveBeenCalled(); + }); + + it("skips injecting the script in manifest v2 when the tab domain is a blocked domain", async () => { + domainSettingsService.blockedInteractionsUris$ = of({ [mockBlockedURI.host]: null }); + manifestVersionSpy.mockReturnValue(2); + + await expect(scriptInjectorService["buildInjectionDetails"]).not.toHaveBeenCalled(); + }); + + it("injects the script in manifest v2 when given combined injection details", async () => { + manifestVersionSpy.mockReturnValue(2); + + await scriptInjectorService.inject({ + tabId, + injectDetails: { + file: combinedManifestVersionFile, + frame: "all_frames", + ...sharedInjectDetails, + }, + }); + + expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabId, { + ...sharedInjectDetails, + allFrames: true, + file: combinedManifestVersionFile, + }); + }); + + it("injects the script in manifest v3 when given combined injection details", async () => { + manifestVersionSpy.mockReturnValue(3); + + await scriptInjectorService.inject({ + tabId, + injectDetails: { + file: combinedManifestVersionFile, + frame: 10, + ...sharedInjectDetails, + }, + }); + + expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith( + tabId, + { ...sharedInjectDetails, frameId: 10, file: combinedManifestVersionFile }, + { world: "ISOLATED" }, + ); + }); }); describe("injection of mv2 specific details", () => { diff --git a/apps/browser/src/platform/services/browser-script-injector.service.ts b/apps/browser/src/platform/services/browser-script-injector.service.ts index 2a6b7397a6c..c2bace669dc 100644 --- a/apps/browser/src/platform/services/browser-script-injector.service.ts +++ b/apps/browser/src/platform/services/browser-script-injector.service.ts @@ -1,3 +1,6 @@ +import { firstValueFrom } from "rxjs"; + +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -12,7 +15,10 @@ import { } from "./abstractions/script-injector.service"; export class BrowserScriptInjectorService extends ScriptInjectorService { + blockedDomains: Set = null; + constructor( + private readonly domainSettingsService: DomainSettingsService, private readonly platformUtilsService: PlatformUtilsService, private readonly logService: LogService, ) { @@ -32,6 +38,28 @@ export class BrowserScriptInjectorService extends ScriptInjectorService { throw new Error("No file specified for script injection"); } + const tab = tabId && (await BrowserApi.getTab(tabId)); + const tabURL = tab?.url ? new URL(tab.url) : null; + + // Check if the tab URI is on the disabled URIs list + let injectionAllowedInTab = true; + const blockedDomains = await firstValueFrom( + this.domainSettingsService.blockedInteractionsUris$, + ); + + if (blockedDomains && tabURL?.hostname) { + const blockedDomainsSet = new Set(Object.keys(blockedDomains)); + + injectionAllowedInTab = !(tabURL && blockedDomainsSet.has(tabURL.hostname)); + } + + if (!injectionAllowedInTab) { + this.logService.warning( + `${injectDetails.file} was not injected because ${tabURL?.hostname || "the tab URI"} is on the user's blocked domains list.`, + ); + return; + } + const injectionDetails = this.buildInjectionDetails(injectDetails, file); if (BrowserApi.isManifestVersion(3)) { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 161c4ca0524..2d53ae7e239 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -75,6 +75,7 @@ import { Fido2V1Component } from "../autofill/popup/fido2/fido2-v1.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; import { AutofillV1Component } from "../autofill/popup/settings/autofill-v1.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; +import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-domains.component"; import { ExcludedDomainsV1Component } from "../autofill/popup/settings/excluded-domains-v1.component"; import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component"; import { NotificationsSettingsV1Component } from "../autofill/popup/settings/notifications-v1.component"; @@ -348,6 +349,12 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, + { + path: "blocked-domains", + component: BlockedDomainsComponent, + canActivate: [authGuard], + data: { elevation: 2 } satisfies RouteDataProperties, + }, ...extensionRefreshSwap(ExcludedDomainsV1Component, ExcludedDomainsComponent, { path: "excluded-domains", canActivate: [authGuard], diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 5b27833636f..92eb8973235 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -327,7 +327,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider], + deps: [StateProvider, ConfigService], }), safeProvider({ provide: AbstractStorageService, @@ -365,7 +365,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: ScriptInjectorService, useClass: BrowserScriptInjectorService, - deps: [PlatformUtilsService, LogService], + deps: [DomainSettingsService, PlatformUtilsService, LogService], }), safeProvider({ provide: VaultTimeoutService, diff --git a/apps/browser/src/tools/background/fileless-importer.background.spec.ts b/apps/browser/src/tools/background/fileless-importer.background.spec.ts index 7b356b18fd5..429a0e12184 100644 --- a/apps/browser/src/tools/background/fileless-importer.background.spec.ts +++ b/apps/browser/src/tools/background/fileless-importer.background.spec.ts @@ -1,9 +1,10 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { of } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -23,18 +24,10 @@ import { FilelessImportPort, FilelessImportType } from "../enums/fileless-import import FilelessImporterBackground from "./fileless-importer.background"; -jest.mock("rxjs", () => { - const rxjs = jest.requireActual("rxjs"); - const { firstValueFrom } = rxjs; - return { - ...rxjs, - firstValueFrom: jest.fn(firstValueFrom), - }; -}); - describe("FilelessImporterBackground ", () => { let filelessImporterBackground: FilelessImporterBackground; const configService = mock(); + const domainSettingsService = mock(); const authService = mock(); const policyService = mock(); const notificationBackground = mock(); @@ -43,9 +36,16 @@ describe("FilelessImporterBackground ", () => { const platformUtilsService = mock(); const logService = mock(); let scriptInjectorService: BrowserScriptInjectorService; + let tabMock: chrome.tabs.Tab; beforeEach(() => { - scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService); + domainSettingsService.blockedInteractionsUris$ = of(null); + policyService.policyAppliesToActiveUser$.mockImplementation(() => of(true)); + scriptInjectorService = new BrowserScriptInjectorService( + domainSettingsService, + platformUtilsService, + logService, + ); filelessImporterBackground = new FilelessImporterBackground( configService, authService, @@ -75,12 +75,13 @@ describe("FilelessImporterBackground ", () => { beforeEach(() => { lpImporterPort = createPortSpyMock(FilelessImportPort.LpImporter); + tabMock = lpImporterPort.sender.tab; + jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock); manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get"); executeScriptInTabSpy = jest.spyOn(BrowserApi, "executeScriptInTab").mockResolvedValue(null); jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked); jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true); jest.spyOn(filelessImporterBackground as any, "removeIndividualVault"); - (firstValueFrom as jest.Mock).mockResolvedValue(false); }); it("ignores the port connection if the port name is not present in the set of filelessImportNames", async () => { @@ -105,8 +106,6 @@ describe("FilelessImporterBackground ", () => { }); it("posts a message to the port indicating that the fileless import feature is disabled if the user's policy removes individual vaults", async () => { - (firstValueFrom as jest.Mock).mockResolvedValue(true); - triggerRuntimeOnConnectEvent(lpImporterPort); await flushPromises(); @@ -129,6 +128,8 @@ describe("FilelessImporterBackground ", () => { }); it("posts a message to the port indicating that the fileless import feature is enabled", async () => { + policyService.policyAppliesToActiveUser$.mockImplementationOnce(() => of(false)); + triggerRuntimeOnConnectEvent(lpImporterPort); await flushPromises(); @@ -139,6 +140,7 @@ describe("FilelessImporterBackground ", () => { }); it("triggers an injection of the `lp-suppress-import-download.js` script in manifest v3", async () => { + policyService.policyAppliesToActiveUser$.mockImplementationOnce(() => of(false)); manifestVersionSpy.mockReturnValue(3); triggerRuntimeOnConnectEvent(lpImporterPort); @@ -152,6 +154,7 @@ describe("FilelessImporterBackground ", () => { }); it("triggers an injection of the `lp-suppress-import-download-script-append-mv2.js` script in manifest v2", async () => { + policyService.policyAppliesToActiveUser$.mockImplementationOnce(() => of(false)); manifestVersionSpy.mockReturnValue(2); triggerRuntimeOnConnectEvent(lpImporterPort); @@ -170,9 +173,10 @@ describe("FilelessImporterBackground ", () => { let lpImporterPort: chrome.runtime.Port; beforeEach(async () => { + policyService.policyAppliesToActiveUser$.mockImplementation(() => of(false)); jest.spyOn(authService, "getAuthStatus").mockResolvedValue(AuthenticationStatus.Unlocked); jest.spyOn(configService, "getFeatureFlag").mockResolvedValue(true); - (firstValueFrom as jest.Mock).mockResolvedValue(false); + triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.NotificationBar)); triggerRuntimeOnConnectEvent(createPortSpyMock(FilelessImportPort.LpImporter)); await flushPromises(); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 2afbae0782f..9f9e45e86d4 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -483,7 +483,29 @@ export class ServiceContainer { this.containerService = new ContainerService(this.keyService, this.encryptService); - this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); + this.configApiService = new ConfigApiService(this.apiService, this.tokenService); + + this.authService = new AuthService( + this.accountService, + this.messagingService, + this.keyService, + this.apiService, + this.stateService, + this.tokenService, + ); + + this.configService = new DefaultConfigService( + this.configApiService, + this.environmentService, + this.logService, + this.stateProvider, + this.authService, + ); + + this.domainSettingsService = new DefaultDomainSettingsService( + this.stateProvider, + this.configService, + ); this.fileUploadService = new FileUploadService(this.logService, this.apiService); @@ -579,25 +601,6 @@ export class ServiceContainer { this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService); - this.authService = new AuthService( - this.accountService, - this.messagingService, - this.keyService, - this.apiService, - this.stateService, - this.tokenService, - ); - - this.configApiService = new ConfigApiService(this.apiService, this.tokenService); - - this.configService = new DefaultConfigService( - this.configApiService, - this.environmentService, - this.logService, - this.stateProvider, - this.authService, - ); - this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); this.deviceTrustService = new DeviceTrustService( this.keyGenerationService, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 149d553696e..583ba82fc98 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -463,6 +463,11 @@ const safeProviders: SafeProvider[] = [ useClass: CipherFileUploadService, deps: [ApiServiceAbstraction, FileUploadServiceAbstraction], }), + safeProvider({ + provide: DomainSettingsService, + useClass: DefaultDomainSettingsService, + deps: [StateProvider, ConfigService], + }), safeProvider({ provide: CipherServiceAbstraction, useFactory: ( @@ -1243,11 +1248,6 @@ const safeProviders: SafeProvider[] = [ useClass: BadgeSettingsService, deps: [StateProvider], }), - safeProvider({ - provide: DomainSettingsService, - useClass: DefaultDomainSettingsService, - deps: [StateProvider], - }), safeProvider({ provide: BiometricStateService, useClass: DefaultBiometricStateService, diff --git a/libs/common/src/autofill/services/domain-settings.service.spec.ts b/libs/common/src/autofill/services/domain-settings.service.spec.ts index 24e3763eb45..a25653f168c 100644 --- a/libs/common/src/autofill/services/domain-settings.service.spec.ts +++ b/libs/common/src/autofill/services/domain-settings.service.spec.ts @@ -1,5 +1,8 @@ +import { MockProxy, mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec"; import { Utils } from "../../platform/misc/utils"; import { UserId } from "../../types/guid"; @@ -8,6 +11,7 @@ import { DefaultDomainSettingsService, DomainSettingsService } from "./domain-se describe("DefaultDomainSettingsService", () => { let domainSettingsService: DomainSettingsService; + let configService: MockProxy; const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); @@ -19,10 +23,13 @@ describe("DefaultDomainSettingsService", () => { ]; beforeEach(() => { - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + configService = mock(); + configService.getFeatureFlag$.mockImplementation(() => of(false)); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); jest.spyOn(domainSettingsService, "getUrlEquivalentDomains"); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); + domainSettingsService.blockedInteractionsUris$ = of(null); }); describe("getUrlEquivalentDomains", () => { diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts index 708341563e0..aeb3af69dae 100644 --- a/libs/common/src/autofill/services/domain-settings.service.ts +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -1,6 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { map, Observable } from "rxjs"; +import { map, Observable, switchMap, of } from "rxjs"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { NeverDomains, @@ -8,6 +10,7 @@ import { UriMatchStrategySetting, UriMatchStrategy, } from "../../models/domain/domain-service"; +import { ConfigService } from "../../platform/abstractions/config/config.service"; import { Utils } from "../../platform/misc/utils"; import { DOMAIN_SETTINGS_DISK, @@ -23,10 +26,20 @@ const SHOW_FAVICONS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "showFavicons", { deserializer: (value: boolean) => value ?? true, }); +// Domain exclusion list for notifications const NEVER_DOMAINS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "neverDomains", { deserializer: (value: NeverDomains) => value ?? null, }); +// Domain exclusion list for content script injections +const BLOCKED_INTERACTIONS_URIS = new KeyDefinition( + DOMAIN_SETTINGS_DISK, + "blockedInteractionsUris", + { + deserializer: (value: NeverDomains) => value ?? null, + }, +); + const EQUIVALENT_DOMAINS = new UserKeyDefinition(DOMAIN_SETTINGS_DISK, "equivalentDomains", { deserializer: (value: EquivalentDomains) => value ?? null, clearOn: ["logout"], @@ -41,15 +54,45 @@ const DEFAULT_URI_MATCH_STRATEGY = new UserKeyDefinition( }, ); +/** + * The Domain Settings service; provides client settings state for "active client view" URI concerns + */ export abstract class DomainSettingsService { + /** + * Indicates if the favicons for ciphers' URIs should be shown instead of a placeholder + */ showFavicons$: Observable; setShowFavicons: (newValue: boolean) => Promise; + + /** + * User-specified URIs for which the client notifications should not appear + */ neverDomains$: Observable; setNeverDomains: (newValue: NeverDomains) => Promise; + + /** + * User-specified URIs for which client content script injections should not occur, and the state + * of banner/notice visibility for those domains within the client + */ + blockedInteractionsUris$: Observable; + setBlockedInteractionsUris: (newValue: NeverDomains) => Promise; + + /** + * URIs which should be treated as equivalent to each other for various concerns (autofill, etc) + */ equivalentDomains$: Observable; setEquivalentDomains: (newValue: EquivalentDomains, userId: UserId) => Promise; + + /** + * User-specified default for URI-matching strategies (for example, when determining relevant + * ciphers for an active browser tab). Can be overridden by cipher-specific settings. + */ defaultUriMatchStrategy$: Observable; setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise; + + /** + * Helper function for the common resolution of a given URL against equivalent domains + */ getUrlEquivalentDomains: (url: string) => Observable>; } @@ -60,19 +103,37 @@ export class DefaultDomainSettingsService implements DomainSettingsService { private neverDomainsState: GlobalState; readonly neverDomains$: Observable; + private blockedInteractionsUrisState: GlobalState; + readonly blockedInteractionsUris$: Observable; + private equivalentDomainsState: ActiveUserState; readonly equivalentDomains$: Observable; private defaultUriMatchStrategyState: ActiveUserState; readonly defaultUriMatchStrategy$: Observable; - constructor(private stateProvider: StateProvider) { + constructor( + private stateProvider: StateProvider, + private configService: ConfigService, + ) { this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS); this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true)); this.neverDomainsState = this.stateProvider.getGlobal(NEVER_DOMAINS); this.neverDomains$ = this.neverDomainsState.state$.pipe(map((x) => x ?? null)); + // Needs to be global to prevent pre-login injections + this.blockedInteractionsUrisState = this.stateProvider.getGlobal(BLOCKED_INTERACTIONS_URIS); + + this.blockedInteractionsUris$ = this.configService + .getFeatureFlag$(FeatureFlag.BlockBrowserInjectionsByDomain) + .pipe( + switchMap((featureIsEnabled) => + featureIsEnabled ? this.blockedInteractionsUrisState.state$ : of({} as NeverDomains), + ), + map((disabledUris) => (Object.keys(disabledUris).length ? disabledUris : null)), + ); + this.equivalentDomainsState = this.stateProvider.getActive(EQUIVALENT_DOMAINS); this.equivalentDomains$ = this.equivalentDomainsState.state$.pipe(map((x) => x ?? null)); @@ -90,6 +151,10 @@ export class DefaultDomainSettingsService implements DomainSettingsService { await this.neverDomainsState.update(() => newValue); } + async setBlockedInteractionsUris(newValue: NeverDomains): Promise { + await this.blockedInteractionsUrisState.update(() => newValue); + } + async setEquivalentDomains(newValue: EquivalentDomains, userId: UserId): Promise { await this.stateProvider.getUser(userId, EQUIVALENT_DOMAINS).update(() => newValue); } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 135119bf133..0ab7d47acfc 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -27,6 +27,7 @@ export enum FeatureFlag { SSHKeyVaultItem = "ssh-key-vault-item", SSHAgent = "ssh-agent", NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements", + BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain", AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api", CipherKeyEncryption = "cipher-key-encryption", VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint", @@ -81,6 +82,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.SSHKeyVaultItem]: FALSE, [FeatureFlag.SSHAgent]: FALSE, [FeatureFlag.NotificationBarAddLoginImprovements]: FALSE, + [FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE, [FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE, diff --git a/libs/common/src/models/domain/domain-service.ts b/libs/common/src/models/domain/domain-service.ts index 9ff53cc8787..a6b5ecfdaac 100644 --- a/libs/common/src/models/domain/domain-service.ts +++ b/libs/common/src/models/domain/domain-service.ts @@ -21,5 +21,5 @@ export const UriMatchStrategy = { export type UriMatchStrategySetting = (typeof UriMatchStrategy)[keyof typeof UriMatchStrategy]; // using uniqueness properties of object shape over Set for ease of state storability -export type NeverDomains = { [id: string]: null }; +export type NeverDomains = { [id: string]: null | { bannerIsDismissed?: boolean } }; export type EquivalentDomains = string[][];