From a49303b1026ab950f10f4c83682fa5ca5eeb328b Mon Sep 17 00:00:00 2001 From: Katherine Reynolds <7054971+reynoldskr@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:50:08 -0800 Subject: [PATCH] Expanded tunnel demo --- .../popup/settings/tunnel-demo.component.ts | 31 ++++++-- .../tools/popup/settings/tunnel.service.ts | 79 +++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 apps/browser/src/tools/popup/settings/tunnel.service.ts diff --git a/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts b/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts index 507b8b631eb..e1f3202a10a 100644 --- a/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts +++ b/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts @@ -23,6 +23,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +import { TunnelService } from "./tunnel.service"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -54,6 +56,7 @@ export class TunnelDemoComponent { private cipherService: CipherService, private accountService: AccountService, private formBuilder: FormBuilder, + private tunnelService: TunnelService, ) {} async submit() { @@ -96,12 +99,26 @@ export class TunnelDemoComponent { const username = tunnelDemoCipher.login.username || "(none)"; const password = tunnelDemoCipher.login.password || "(none)"; - await this.dialogService.openSimpleDialog({ - title: "Tunnel Demo - Credentials Retrieved", - content: `Username: ${username}\nPassword: ${password}`, - type: "info", - acceptButtonText: { key: "ok" }, - cancelButtonText: null, - }); + // Send credentials to the localhost tunnel server + try { + await this.tunnelService.sendCredentials({ username, password }); + + await this.dialogService.openSimpleDialog({ + title: "Tunnel Demo - Success", + content: `Credentials successfully sent to tunnel server.\n\nUsername: + ${username}\nPassword: ${password.replace(/./g, "*")}`, + type: "success", + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + }); + } catch (error) { + await this.dialogService.openSimpleDialog({ + title: "Tunnel Demo - Error", + content: `Failed to send credentials to tunnel server: ${error.message || error}`, + type: "danger", + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + }); + } } } diff --git a/apps/browser/src/tools/popup/settings/tunnel.service.ts b/apps/browser/src/tools/popup/settings/tunnel.service.ts new file mode 100644 index 00000000000..9bcb13020e4 --- /dev/null +++ b/apps/browser/src/tools/popup/settings/tunnel.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from "@angular/core"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +export interface TunnelCredentials { + username: string; + password: string; +} + +/** + * Service for securely sending credentials to a localhost tunnel server. + * SECURITY: Only allows connections to loopback addresses (localhost/127.0.0.1/[::1]). + */ +@Injectable({ + providedIn: "root", +}) +export class TunnelService { + private readonly TUNNEL_PORT = 8086; + private readonly TUNNEL_ENDPOINT = "/bwTunnelDemo"; + private readonly ALLOWED_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1"]; + + constructor(private logService: LogService) {} + + /** + * Sends credentials to the local tunnel server. + * @param credentials The username and password to send + * @throws Error if the connection is not to a loopback address or if the request fails + */ + async sendCredentials(credentials: TunnelCredentials): Promise { + const url = `http://localhost:${this.TUNNEL_PORT}${this.TUNNEL_ENDPOINT}`; + + // Validate that we're only connecting to localhost + const urlObj = new URL(url); + if (!this.isLoopbackAddress(urlObj.hostname)) { + const error = `Security violation: Attempted to connect to non-loopback address: ${urlObj.hostname}`; + this.logService.error(error); + throw new Error("Tunnel service only allows connections to localhost"); + } + + try { + const request = new Request(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(credentials), + mode: "cors", + credentials: "omit", + cache: "no-store", + }); + + const response = await fetch(request); + + if (!response.ok) { + throw new Error( + `Tunnel server responded with status ${response.status}: ${response.statusText}`, + ); + } + + this.logService.info("Credentials successfully sent to tunnel server"); + } catch (error) { + this.logService.error(`Failed to send credentials to tunnel server: ${error}`); + throw error; + } + } + + /** + * Validates that a hostname is a loopback address. + * @param hostname The hostname to validate + * @returns true if the hostname is a loopback address + */ + private isLoopbackAddress(hostname: string): boolean { + // Normalize hostname to lowercase for comparison + const normalizedHost = hostname.toLowerCase(); + + // Check against known loopback addresses + return this.ALLOWED_LOOPBACK_HOSTS.includes(normalizedHost); + } +}