mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 22:33:35 +00:00
[PM-25250] Prevent configuration and access of self hosted urls over http (#17095)
* feat: ban urls not using https * feat: add exception for dev env * feat: block fetching of insecure URLs * feat: add exception for dev env * feat: block notifications from using insecure URL * fix: bug where submission was possible regardless of error * feat: add exception for dev env * fix: missing constructor param
This commit is contained in:
@@ -1641,6 +1641,9 @@
|
|||||||
"selfHostedEnvFormInvalid": {
|
"selfHostedEnvFormInvalid": {
|
||||||
"message": "You must add either the base Server URL or at least one custom environment."
|
"message": "You must add either the base Server URL or at least one custom environment."
|
||||||
},
|
},
|
||||||
|
"selfHostedEnvMustUseHttps": {
|
||||||
|
"message": "URLs must use HTTPS."
|
||||||
|
},
|
||||||
"customEnvironment": {
|
"customEnvironment": {
|
||||||
"message": "Custom environment"
|
"message": "Custom environment"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1215,7 +1215,7 @@ export default class MainBackground {
|
|||||||
logoutCallback,
|
logoutCallback,
|
||||||
this.messagingService,
|
this.messagingService,
|
||||||
this.accountService,
|
this.accountService,
|
||||||
new SignalRConnectionService(this.apiService, this.logService),
|
new SignalRConnectionService(this.apiService, this.logService, this.platformUtilsService),
|
||||||
this.authService,
|
this.authService,
|
||||||
this.webPushConnectionService,
|
this.webPushConnectionService,
|
||||||
this.authRequestAnsweringService,
|
this.authRequestAnsweringService,
|
||||||
|
|||||||
@@ -1035,6 +1035,9 @@
|
|||||||
"selfHostedEnvFormInvalid": {
|
"selfHostedEnvFormInvalid": {
|
||||||
"message": "You must add either the base Server URL or at least one custom environment."
|
"message": "You must add either the base Server URL or at least one custom environment."
|
||||||
},
|
},
|
||||||
|
"selfHostedEnvMustUseHttps": {
|
||||||
|
"message": "URLs must use HTTPS."
|
||||||
|
},
|
||||||
"customEnvironment": {
|
"customEnvironment": {
|
||||||
"message": "Custom environment"
|
"message": "Custom environment"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7132,6 +7132,9 @@
|
|||||||
"selfHostedEnvFormInvalid": {
|
"selfHostedEnvFormInvalid": {
|
||||||
"message": "You must add either the base Server URL or at least one custom environment."
|
"message": "You must add either the base Server URL or at least one custom environment."
|
||||||
},
|
},
|
||||||
|
"selfHostedEnvMustUseHttps": {
|
||||||
|
"message": "URLs must use HTTPS."
|
||||||
|
},
|
||||||
"apiUrl": {
|
"apiUrl": {
|
||||||
"message": "API server URL"
|
"message": "API server URL"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -968,7 +968,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
provide: SignalRConnectionService,
|
provide: SignalRConnectionService,
|
||||||
useClass: SignalRConnectionService,
|
useClass: SignalRConnectionService,
|
||||||
deps: [ApiServiceAbstraction, LogService],
|
deps: [ApiServiceAbstraction, LogService, PlatformUtilsServiceAbstraction],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: WebPushConnectionService,
|
provide: WebPushConnectionService,
|
||||||
@@ -1223,7 +1223,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
provide: AnonymousHubServiceAbstraction,
|
provide: AnonymousHubServiceAbstraction,
|
||||||
useClass: AnonymousHubService,
|
useClass: AnonymousHubService,
|
||||||
deps: [EnvironmentService, AuthRequestServiceAbstraction],
|
deps: [EnvironmentService, AuthRequestServiceAbstraction, PlatformUtilsServiceAbstraction],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: ValidationServiceAbstraction,
|
provide: ValidationServiceAbstraction,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
import { Component, inject, OnDestroy, OnInit } from "@angular/core";
|
||||||
import {
|
import {
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
EnvironmentService,
|
EnvironmentService,
|
||||||
Region,
|
Region,
|
||||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +53,25 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onlyHttpsValidator(): ValidatorFn {
|
||||||
|
const i18nService = inject(I18nService);
|
||||||
|
const platformUtilsService = inject(PlatformUtilsService);
|
||||||
|
|
||||||
|
return (control: AbstractControl): ValidationErrors | null => {
|
||||||
|
const url = control.value as string;
|
||||||
|
|
||||||
|
if (url && !url.startsWith("https://") && !platformUtilsService.isDev()) {
|
||||||
|
return {
|
||||||
|
onlyHttpsAllowed: {
|
||||||
|
message: i18nService.t("selfHostedEnvMustUseHttps"),
|
||||||
|
},
|
||||||
|
}; // invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // valid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog for configuring self-hosted environment settings.
|
* Dialog for configuring self-hosted environment settings.
|
||||||
*/
|
*/
|
||||||
@@ -89,12 +110,12 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
formGroup = this.formBuilder.group(
|
formGroup = this.formBuilder.group(
|
||||||
{
|
{
|
||||||
baseUrl: [""],
|
baseUrl: ["", [onlyHttpsValidator()]],
|
||||||
webVaultUrl: [""],
|
webVaultUrl: ["", [onlyHttpsValidator()]],
|
||||||
apiUrl: [""],
|
apiUrl: ["", [onlyHttpsValidator()]],
|
||||||
identityUrl: [""],
|
identityUrl: ["", [onlyHttpsValidator()]],
|
||||||
iconsUrl: [""],
|
iconsUrl: ["", [onlyHttpsValidator()]],
|
||||||
notificationsUrl: [""],
|
notificationsUrl: ["", [onlyHttpsValidator()]],
|
||||||
},
|
},
|
||||||
{ validators: selfHostedEnvSettingsFormValidator() },
|
{ validators: selfHostedEnvSettingsFormValidator() },
|
||||||
);
|
);
|
||||||
@@ -162,10 +183,11 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
submit = async () => {
|
submit = async () => {
|
||||||
|
this.formGroup.markAllAsTouched();
|
||||||
this.showErrorSummary = false;
|
this.showErrorSummary = false;
|
||||||
|
|
||||||
if (this.formGroup.invalid) {
|
if (this.formGroup.invalid) {
|
||||||
this.showErrorSummary = true;
|
this.showErrorSummary = Boolean(this.formGroup.errors?.["atLeastOneUrlIsRequired"]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
NotificationResponse,
|
NotificationResponse,
|
||||||
} from "../../models/response/notification.response";
|
} from "../../models/response/notification.response";
|
||||||
import { EnvironmentService } from "../../platform/abstractions/environment.service";
|
import { EnvironmentService } from "../../platform/abstractions/environment.service";
|
||||||
|
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||||
|
import { InsecureUrlNotAllowedError } from "../../services/api-errors";
|
||||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service";
|
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service";
|
||||||
|
|
||||||
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||||
@@ -27,10 +29,14 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
|||||||
constructor(
|
constructor(
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
private authRequestService: AuthRequestServiceAbstraction,
|
private authRequestService: AuthRequestServiceAbstraction,
|
||||||
|
private platformUtilsService: PlatformUtilsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createHubConnection(token: string) {
|
async createHubConnection(token: string) {
|
||||||
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
|
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
|
||||||
|
if (!this.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||||
|
throw new InsecureUrlNotAllowedError();
|
||||||
|
}
|
||||||
|
|
||||||
this.anonHubConnection = new HubConnectionBuilder()
|
this.anonHubConnection = new HubConnectionBuilder()
|
||||||
.withUrl(this.url + "/anonymous-hub?Token=" + token, {
|
.withUrl(this.url + "/anonymous-hub?Token=" + token, {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { Observable, Subscription } from "rxjs";
|
|||||||
|
|
||||||
import { ApiService } from "../../../abstractions/api.service";
|
import { ApiService } from "../../../abstractions/api.service";
|
||||||
import { NotificationResponse } from "../../../models/response/notification.response";
|
import { NotificationResponse } from "../../../models/response/notification.response";
|
||||||
|
import { InsecureUrlNotAllowedError } from "../../../services/api-errors";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
import { LogService } from "../../abstractions/log.service";
|
import { LogService } from "../../abstractions/log.service";
|
||||||
|
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
|
||||||
|
|
||||||
// 2 Minutes
|
// 2 Minutes
|
||||||
const MIN_RECONNECT_TIME = 2 * 60 * 1000;
|
const MIN_RECONNECT_TIME = 2 * 60 * 1000;
|
||||||
@@ -69,12 +71,17 @@ export class SignalRConnectionService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly logService: LogService,
|
private readonly logService: LogService,
|
||||||
|
private readonly platformUtilsService: PlatformUtilsService,
|
||||||
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
|
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
|
||||||
new HubConnectionBuilder(),
|
new HubConnectionBuilder(),
|
||||||
private readonly timeoutManager: TimeoutManager = globalThis,
|
private readonly timeoutManager: TimeoutManager = globalThis,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
connect$(userId: UserId, notificationsUrl: string) {
|
connect$(userId: UserId, notificationsUrl: string) {
|
||||||
|
if (!notificationsUrl.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||||
|
throw new InsecureUrlNotAllowedError();
|
||||||
|
}
|
||||||
|
|
||||||
return new Observable<SignalRNotification>((subsciber) => {
|
return new Observable<SignalRNotification>((subsciber) => {
|
||||||
const connection = this.hubConnectionBuilderFactory()
|
const connection = this.hubConnectionBuilderFactory()
|
||||||
.withUrl(notificationsUrl + "/hub", {
|
.withUrl(notificationsUrl + "/hub", {
|
||||||
|
|||||||
9
libs/common/src/services/api-errors.ts
Normal file
9
libs/common/src/services/api-errors.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export class InsecureUrlNotAllowedError extends Error {
|
||||||
|
constructor(url?: string) {
|
||||||
|
if (url === undefined) {
|
||||||
|
super("Insecure URL not allowed. All URLs must use HTTPS.");
|
||||||
|
} else {
|
||||||
|
super(`Insecure URL not allowed: ${url}. All URLs must use HTTPS.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { Environment, EnvironmentService } from "../platform/abstractions/enviro
|
|||||||
import { LogService } from "../platform/abstractions/log.service";
|
import { LogService } from "../platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
||||||
|
|
||||||
|
import { InsecureUrlNotAllowedError } from "./api-errors";
|
||||||
import { ApiService, HttpOperations } from "./api.service";
|
import { ApiService, HttpOperations } from "./api.service";
|
||||||
|
|
||||||
describe("ApiService", () => {
|
describe("ApiService", () => {
|
||||||
@@ -411,4 +412,39 @@ describe("ApiService", () => {
|
|||||||
).rejects.toMatchObject(error);
|
).rejects.toMatchObject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("throws error when trying to fetch an insecure URL", async () => {
|
||||||
|
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||||
|
of({
|
||||||
|
getApiUrl: () => "http://example.com",
|
||||||
|
} satisfies Partial<Environment> as Environment),
|
||||||
|
);
|
||||||
|
|
||||||
|
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||||
|
return {
|
||||||
|
url: url,
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
method: request.method,
|
||||||
|
mode: request.mode,
|
||||||
|
signal: request.signal ?? undefined,
|
||||||
|
headers: new Headers(request.headers),
|
||||||
|
} satisfies Partial<Request> as unknown as Request;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||||
|
nativeFetch.mockImplementation((request) => {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
headers: new Headers(),
|
||||||
|
} satisfies Partial<Response> as Response);
|
||||||
|
});
|
||||||
|
sut.nativeFetch = nativeFetch;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
async () => await sut.send("GET", "/something", null, true, true, null),
|
||||||
|
).rejects.toThrow(InsecureUrlNotAllowedError);
|
||||||
|
expect(nativeFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ import { AttachmentResponse } from "../vault/models/response/attachment.response
|
|||||||
import { CipherResponse } from "../vault/models/response/cipher.response";
|
import { CipherResponse } from "../vault/models/response/cipher.response";
|
||||||
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
|
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
|
||||||
|
|
||||||
|
import { InsecureUrlNotAllowedError } from "./api-errors";
|
||||||
|
|
||||||
export type HttpOperations = {
|
export type HttpOperations = {
|
||||||
createRequest: (url: string, request: RequestInit) => Request;
|
createRequest: (url: string, request: RequestInit) => Request;
|
||||||
};
|
};
|
||||||
@@ -1310,6 +1312,10 @@ export class ApiService implements ApiServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetch(request: Request): Promise<Response> {
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
if (!request.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||||
|
throw new InsecureUrlNotAllowedError();
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method === "GET") {
|
if (request.method === "GET") {
|
||||||
request.headers.set("Cache-Control", "no-store");
|
request.headers.set("Cache-Control", "no-store");
|
||||||
request.headers.set("Pragma", "no-cache");
|
request.headers.set("Pragma", "no-cache");
|
||||||
|
|||||||
Reference in New Issue
Block a user