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

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

View File

@@ -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<LogService>;
let cipherService: MockProxy<CipherService>;
let autofillService: MockProxy<AutofillService>;
let configService: MockProxy<ConfigService>;
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
let authService: MockProxy<AuthService>;
let environmentMock$: BehaviorSubject<CloudEnvironment>;
@@ -149,11 +151,13 @@ describe("OverlayBackground", () => {
}
beforeEach(() => {
configService = mock<ConfigService>();
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<LogService>();

View File

@@ -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<CipherService>();
const autofillService = mock<AutofillService>();
let configService: MockProxy<ConfigService>;
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
let authService: MockProxy<AuthService>;
@@ -92,7 +94,9 @@ describe("OverlayBackground", () => {
};
beforeEach(() => {
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
configService = mock<ConfigService>();
configService.getFeatureFlag$.mockImplementation(() => of(true));
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService);
activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked);
authService = mock<AuthService>();
authService.activeAccountStatus$ = activeAccountStatusMock$;

View File

@@ -255,4 +255,16 @@
{{ "showIdentitiesCurrentTabDesc" | i18n }}
</div>
</div>
<div class="box list" *ngIf="blockBrowserInjectionsByDomainEnabled">
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/blocked-domains"
>
<div class="row-main">{{ "blockedDomains" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
</main>

View File

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

View File

@@ -282,5 +282,11 @@
</bit-form-field>
</bit-card>
</bit-section>
<bit-section *ngIf="blockBrowserInjectionsByDomainEnabled">
<bit-item>
<a bit-item-content routerLink="/blocked-domains">{{ "blockedDomains" | i18n }}</a>
<i slot="end" class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</bit-item>
</bit-section>
</div>
</popup-page>

View File

@@ -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$));

View File

@@ -0,0 +1,66 @@
<popup-page>
<popup-header slot="header" pageTitle="{{ 'blockedDomains' | i18n }}" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<div class="tw-bg-background-alt">
<p>{{ "blockedDomainsDesc" | i18n }}</p>
<bit-section *ngIf="!isLoading">
<bit-section-header>
<h2 bitTypography="h6">{{ "domainsTitle" | i18n }}</h2>
<span bitTypography="body2" slot="end">{{ blockedDomainsState?.length || 0 }}</span>
</bit-section-header>
<ng-container *ngIf="blockedDomainsState">
<bit-item
*ngFor="let domain of blockedDomainsState; let i = index; trackBy: trackByFunction"
>
<bit-item-content>
<bit-label *ngIf="i >= fieldsEditThreshold">{{
"websiteItemLabel" | i18n: i + 1
}}</bit-label>
<input
*ngIf="i >= fieldsEditThreshold"
#uriInput
appInputVerbatim
bitInput
id="excludedDomain{{ i }}"
inputmode="url"
name="excludedDomain{{ i }}"
type="text"
(change)="fieldChange()"
[(ngModel)]="blockedDomainsState[i]"
/>
<div id="excludedDomain{{ i }}" *ngIf="i < fieldsEditThreshold">{{ domain }}</div>
</bit-item-content>
<button
*ngIf="i < fieldsEditThreshold"
appA11yTitle="{{ 'remove' | i18n }}"
bitIconButton="bwi-minus-circle"
buttonType="danger"
size="small"
slot="end"
type="button"
(click)="removeDomain(i)"
></button>
</bit-item>
</ng-container>
<button bitLink class="tw-pt-2" type="button" linkType="primary" (click)="addNewDomain()">
<i class="bwi bwi-plus-circle bwi-fw" aria-hidden="true"></i> {{ "addDomain" | i18n }}
</button>
</bit-section>
</div>
<popup-footer slot="footer">
<button
bitButton
buttonType="primary"
type="submit"
[disabled]="dataIsPristine"
(click)="saveChanges()"
>
{{ "save" | i18n }}
</button>
</popup-footer>
</popup-page>

View File

@@ -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<ElementRef<HTMLInputElement>> =
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<void>();
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;
}
}

View File

@@ -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<ElementRef<HTMLInputElement>>;
@ViewChildren("uriInput") uriInputElements: QueryList<ElementRef<HTMLInputElement>> =
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;

View File

@@ -98,7 +98,13 @@ describe("AutofillService", () => {
let messageListener: MockProxy<MessageListener>;
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
configService = mock<ConfigService>();
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>();
authService.activeAccountStatus$ = activeAccountStatusMock$;
configService = mock<ConfigService>();
messageListener = mock<MessageListener>();
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")

View File

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

View File

@@ -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<PlatformUtilsService>();
const logService = mock<LogService>();
const platformUtilsService = mock<PlatformUtilsService>();
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
let configService: MockProxy<ConfigService>;
let domainSettingsService: DomainSettingsService;
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock);
configService = mock<ConfigService>();
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", () => {

View File

@@ -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<string> = 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)) {

View File

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

View File

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

View File

@@ -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<ConfigService>();
const domainSettingsService = mock<DomainSettingsService>();
const authService = mock<AuthService>();
const policyService = mock<PolicyService>();
const notificationBackground = mock<NotificationBackground>();
@@ -43,9 +36,16 @@ describe("FilelessImporterBackground ", () => {
const platformUtilsService = mock<PlatformUtilsService>();
const logService = mock<LogService>();
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();

View File

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