From 890ad15934be63637d5ad0b454f4d37c790bbee1 Mon Sep 17 00:00:00 2001
From: Gandalf_Le_Dev <46865726+Gandalf-Le-Dev@users.noreply.github.com>
Date: Wed, 15 Apr 2026 21:10:37 +0200
Subject: [PATCH] fix(desktop): accept self-signed certs from configured
external goosed host (#8400)
Signed-off-by: jh-block
+ {intl.formatMessage(i18n.certFingerprintHelp)} +
+Note: {intl.formatMessage(i18n.restartNote)} diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 9f211e81..0dafe833 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -142,6 +142,7 @@ export interface ExternalGoosedConfig { enabled: boolean; url?: string; secret?: string; + certFingerprint?: string; } export interface StartGoosedOptions { diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index a2655c06..36cc72d6 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -1136,6 +1136,15 @@ "extensionsView.searchPlaceholder": { "defaultMessage": "Search extensions..." }, + "externalBackendSection.certFingerprint": { + "defaultMessage": "Certificate Fingerprint (optional)" + }, + "externalBackendSection.certFingerprintHelp": { + "defaultMessage": "Pin a specific TLS certificate fingerprint. If omitted, the certificate is trusted on first use (TOFU)." + }, + "externalBackendSection.certFingerprintPlaceholder": { + "defaultMessage": "AA:BB:CC:... or sha256/base64" + }, "externalBackendSection.description": { "defaultMessage": "By default goose launches a server for you, use this to connect to an external goose server" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index cdef3f0a..821ae797 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -117,17 +117,26 @@ async function configureProxy() { if (started) app.quit(); -// Accept self-signed certificates from the local goosed server. +// Certificate trust for goosed servers (local and external). // Both certificate-error (renderer) and setCertificateVerifyProc (main-process -// net.fetch) pin to the exact cert fingerprint emitted by goosed at startup. -// Before the fingerprint is available (during the health-check bootstrap -// window) any localhost cert is accepted so the server can come up. +// net.fetch) pin to the exact cert fingerprint. For locally-spawned goosed the +// fingerprint comes from its stdout; for external backends we use Trust-On-First-Use +// (TOFU) — the first TLS handshake pins the cert for the lifetime of the process. let pinnedCertFingerprint: string | null = null; +// Cached hostname of the configured external goosed server, updated when a +// chat is created so we don't hit the filesystem on every TLS handshake. +let trustedExternalHostname: string | null = null; + function isLocalhost(hostname: string): boolean { return hostname === '127.0.0.1' || hostname === 'localhost'; } +function isTrustedHost(hostname: string): boolean { + if (isLocalhost(hostname)) return true; + return trustedExternalHostname !== null && hostname === trustedExternalHostname; +} + function normalizeFingerprint(fp: string): string { if (fp.startsWith('sha256/')) { const b64 = fp.slice('sha256/'.length); @@ -145,7 +154,7 @@ function normalizeFingerprint(fp: string): string { // window) any localhost cert is accepted so the server can come up. app.on('certificate-error', (event, _webContents, url, _error, certificate, callback) => { const parsed = new URL(url); - if (!isLocalhost(parsed.hostname)) { + if (!isTrustedHost(parsed.hostname)) { callback(false); return; } @@ -155,6 +164,8 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call event.preventDefault(); callback(match); } else { + // TOFU: pin the certificate from the first successful handshake. + pinnedCertFingerprint = normalizeFingerprint(certificate.fingerprint); event.preventDefault(); callback(true); } @@ -163,17 +174,19 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call // Main-process net.fetch: pin to the exact cert goosed generated. app.whenReady().then(() => { session.defaultSession.setCertificateVerifyProc((request, callback) => { - if (!isLocalhost(request.hostname)) { + if (!isTrustedHost(request.hostname)) { callback(-3); return; } if (!pinnedCertFingerprint) { + // TOFU: pin the certificate from the first successful handshake. + pinnedCertFingerprint = normalizeFingerprint(request.certificate.fingerprint); callback(0); return; } const match = normalizeFingerprint(request.certificate.fingerprint) === pinnedCertFingerprint.toUpperCase(); - callback(match ? 0 : -3); + callback(match ? 0 : -2); }); }); @@ -574,6 +587,26 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { const settings = getSettings(); const serverSecret = getServerSecret(settings); + // Update the cached trusted-external-hostname so the TLS handlers allow + // connections to the configured remote backend. + if (settings.externalGoosed?.enabled && settings.externalGoosed.url) { + try { + trustedExternalHostname = new URL(settings.externalGoosed.url).hostname; + } catch { + trustedExternalHostname = null; + } + } else { + trustedExternalHostname = null; + } + + // If the user provided a cert fingerprint for the external backend, pin it + // directly (skips TOFU). Otherwise reset so the first handshake pins via TOFU. + if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) { + pinnedCertFingerprint = normalizeFingerprint(settings.externalGoosed.certFingerprint); + } else { + pinnedCertFingerprint = null; + } + const goosedResult = await startGoosed({ serverSecret, dir: dir || os.homedir(), @@ -586,8 +619,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { logger: log, }); - // Pin the certificate fingerprint so the cert handlers above only accept - // the exact cert that *this* goosed instance generated. + // For locally-spawned goosed, pin using the fingerprint from stdout. + // For external backends the TOFU path in the cert handlers will pin + // the fingerprint on the first successful TLS handshake. if (goosedResult.certFingerprint) { pinnedCertFingerprint = goosedResult.certFingerprint; } diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index f23cdd59..6e491b9c 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -2,6 +2,7 @@ export interface ExternalGoosedConfig { enabled: boolean; url: string; secret: string; + certFingerprint?: string; } export interface KeyboardShortcuts {