1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[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
This commit is contained in:
Jonathan Prusik
2025-01-06 17:10:34 -05:00
committed by GitHub
parent ddc817689a
commit 15faf52f57
23 changed files with 623 additions and 77 deletions

View File

@@ -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<ConfigService>;
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>();
configService.getFeatureFlag$.mockImplementation(() => of(false));
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService);
jest.spyOn(domainSettingsService, "getUrlEquivalentDomains");
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
domainSettingsService.blockedInteractionsUris$ = of(null);
});
describe("getUrlEquivalentDomains", () => {

View File

@@ -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<boolean>;
setShowFavicons: (newValue: boolean) => Promise<void>;
/**
* User-specified URIs for which the client notifications should not appear
*/
neverDomains$: Observable<NeverDomains>;
setNeverDomains: (newValue: NeverDomains) => Promise<void>;
/**
* 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<NeverDomains>;
setBlockedInteractionsUris: (newValue: NeverDomains) => Promise<void>;
/**
* URIs which should be treated as equivalent to each other for various concerns (autofill, etc)
*/
equivalentDomains$: Observable<EquivalentDomains>;
setEquivalentDomains: (newValue: EquivalentDomains, userId: UserId) => Promise<void>;
/**
* 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<UriMatchStrategySetting>;
setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise<void>;
/**
* Helper function for the common resolution of a given URL against equivalent domains
*/
getUrlEquivalentDomains: (url: string) => Observable<Set<string>>;
}
@@ -60,19 +103,37 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
private neverDomainsState: GlobalState<NeverDomains>;
readonly neverDomains$: Observable<NeverDomains>;
private blockedInteractionsUrisState: GlobalState<NeverDomains>;
readonly blockedInteractionsUris$: Observable<NeverDomains>;
private equivalentDomainsState: ActiveUserState<EquivalentDomains>;
readonly equivalentDomains$: Observable<EquivalentDomains>;
private defaultUriMatchStrategyState: ActiveUserState<UriMatchStrategySetting>;
readonly defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
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<void> {
await this.blockedInteractionsUrisState.update(() => newValue);
}
async setEquivalentDomains(newValue: EquivalentDomains, userId: UserId): Promise<void> {
await this.stateProvider.getUser(userId, EQUIVALENT_DOMAINS).update(() => newValue);
}

View File

@@ -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,

View File

@@ -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[][];