1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +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:
Andreas Coroiu
2025-10-31 08:12:44 +01:00
committed by GitHub
parent 2dd314e992
commit 48fb8b2bfe
11 changed files with 106 additions and 11 deletions

View File

@@ -1641,6 +1641,9 @@
"selfHostedEnvFormInvalid": {
"message": "You must add either the base Server URL or at least one custom environment."
},
"selfHostedEnvMustUseHttps": {
"message": "URLs must use HTTPS."
},
"customEnvironment": {
"message": "Custom environment"
},

View File

@@ -1215,7 +1215,7 @@ export default class MainBackground {
logoutCallback,
this.messagingService,
this.accountService,
new SignalRConnectionService(this.apiService, this.logService),
new SignalRConnectionService(this.apiService, this.logService, this.platformUtilsService),
this.authService,
this.webPushConnectionService,
this.authRequestAnsweringService,

View File

@@ -1035,6 +1035,9 @@
"selfHostedEnvFormInvalid": {
"message": "You must add either the base Server URL or at least one custom environment."
},
"selfHostedEnvMustUseHttps": {
"message": "URLs must use HTTPS."
},
"customEnvironment": {
"message": "Custom environment"
},

View File

@@ -7132,6 +7132,9 @@
"selfHostedEnvFormInvalid": {
"message": "You must add either the base Server URL or at least one custom environment."
},
"selfHostedEnvMustUseHttps": {
"message": "URLs must use HTTPS."
},
"apiUrl": {
"message": "API server URL"
},

View File

@@ -968,7 +968,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: SignalRConnectionService,
useClass: SignalRConnectionService,
deps: [ApiServiceAbstraction, LogService],
deps: [ApiServiceAbstraction, LogService, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: WebPushConnectionService,
@@ -1223,7 +1223,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: AnonymousHubServiceAbstraction,
useClass: AnonymousHubService,
deps: [EnvironmentService, AuthRequestServiceAbstraction],
deps: [EnvironmentService, AuthRequestServiceAbstraction, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: ValidationServiceAbstraction,

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Component, inject, OnDestroy, OnInit } from "@angular/core";
import {
AbstractControl,
FormBuilder,
@@ -16,6 +16,8 @@ import {
EnvironmentService,
Region,
} 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.
// eslint-disable-next-line no-restricted-imports
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.
*/
@@ -89,12 +110,12 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
formGroup = this.formBuilder.group(
{
baseUrl: [""],
webVaultUrl: [""],
apiUrl: [""],
identityUrl: [""],
iconsUrl: [""],
notificationsUrl: [""],
baseUrl: ["", [onlyHttpsValidator()]],
webVaultUrl: ["", [onlyHttpsValidator()]],
apiUrl: ["", [onlyHttpsValidator()]],
identityUrl: ["", [onlyHttpsValidator()]],
iconsUrl: ["", [onlyHttpsValidator()]],
notificationsUrl: ["", [onlyHttpsValidator()]],
},
{ validators: selfHostedEnvSettingsFormValidator() },
);
@@ -162,10 +183,11 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
});
}
submit = async () => {
this.formGroup.markAllAsTouched();
this.showErrorSummary = false;
if (this.formGroup.invalid) {
this.showErrorSummary = true;
this.showErrorSummary = Boolean(this.formGroup.errors?.["atLeastOneUrlIsRequired"]);
return;
}

View File

@@ -18,6 +18,8 @@ import {
NotificationResponse,
} from "../../models/response/notification.response";
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";
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
@@ -27,10 +29,14 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
constructor(
private environmentService: EnvironmentService,
private authRequestService: AuthRequestServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
) {}
async createHubConnection(token: string) {
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
if (!this.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
throw new InsecureUrlNotAllowedError();
}
this.anonHubConnection = new HubConnectionBuilder()
.withUrl(this.url + "/anonymous-hub?Token=" + token, {

View File

@@ -10,8 +10,10 @@ import { Observable, Subscription } from "rxjs";
import { ApiService } from "../../../abstractions/api.service";
import { NotificationResponse } from "../../../models/response/notification.response";
import { InsecureUrlNotAllowedError } from "../../../services/api-errors";
import { UserId } from "../../../types/guid";
import { LogService } from "../../abstractions/log.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
// 2 Minutes
const MIN_RECONNECT_TIME = 2 * 60 * 1000;
@@ -69,12 +71,17 @@ export class SignalRConnectionService {
constructor(
private readonly apiService: ApiService,
private readonly logService: LogService,
private readonly platformUtilsService: PlatformUtilsService,
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
new HubConnectionBuilder(),
private readonly timeoutManager: TimeoutManager = globalThis,
) {}
connect$(userId: UserId, notificationsUrl: string) {
if (!notificationsUrl.startsWith("https://") && !this.platformUtilsService.isDev()) {
throw new InsecureUrlNotAllowedError();
}
return new Observable<SignalRNotification>((subsciber) => {
const connection = this.hubConnectionBuilderFactory()
.withUrl(notificationsUrl + "/hub", {

View 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.`);
}
}
}

View File

@@ -20,6 +20,7 @@ import { Environment, EnvironmentService } from "../platform/abstractions/enviro
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import { InsecureUrlNotAllowedError } from "./api-errors";
import { ApiService, HttpOperations } from "./api.service";
describe("ApiService", () => {
@@ -411,4 +412,39 @@ describe("ApiService", () => {
).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();
});
});

View File

@@ -117,6 +117,8 @@ import { AttachmentResponse } from "../vault/models/response/attachment.response
import { CipherResponse } from "../vault/models/response/cipher.response";
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
import { InsecureUrlNotAllowedError } from "./api-errors";
export type HttpOperations = {
createRequest: (url: string, request: RequestInit) => Request;
};
@@ -1310,6 +1312,10 @@ export class ApiService implements ApiServiceAbstraction {
}
async fetch(request: Request): Promise<Response> {
if (!request.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
throw new InsecureUrlNotAllowedError();
}
if (request.method === "GET") {
request.headers.set("Cache-Control", "no-store");
request.headers.set("Pragma", "no-cache");