fix(desktop): accept self-signed certs from configured external goosed host (#8400)

Signed-off-by: jh-block <jhugo@block.xyz>
Co-authored-by: Mathis Rocher <mathis.rocher@corp.ovh.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jh-block <jhugo@block.xyz>
This commit is contained in:
Gandalf_Le_Dev
2026-04-15 21:10:37 +02:00
committed by GitHub
parent cff0992aef
commit 890ad15934
10 changed files with 138 additions and 12 deletions
@@ -41,6 +41,18 @@ const i18n = defineMessages({
id: 'externalBackendSection.secretKeyHelp',
defaultMessage: 'The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY)',
},
certFingerprint: {
id: 'externalBackendSection.certFingerprint',
defaultMessage: 'Certificate Fingerprint (optional)',
},
certFingerprintPlaceholder: {
id: 'externalBackendSection.certFingerprintPlaceholder',
defaultMessage: 'AA:BB:CC:... or sha256/base64',
},
certFingerprintHelp: {
id: 'externalBackendSection.certFingerprintHelp',
defaultMessage: 'Pin a specific TLS certificate fingerprint. If omitted, the certificate is trusted on first use (TOFU).',
},
restartNote: {
id: 'externalBackendSection.restartNote',
defaultMessage:
@@ -189,6 +201,25 @@ export default function ExternalBackendSection() {
</p>
</div>
<div className="space-y-2">
<label htmlFor="external-cert-fingerprint" className="text-text-primary text-xs">
{intl.formatMessage(i18n.certFingerprint)}
</label>
<Input
id="external-cert-fingerprint"
type="text"
placeholder={intl.formatMessage(i18n.certFingerprintPlaceholder)}
value={config.certFingerprint || ''}
onChange={(e) => updateField('certFingerprint', e.target.value)}
onBlur={() => saveConfig(config)}
disabled={isSaving}
className="font-mono text-xs"
/>
<p className="text-xs text-text-secondary">
{intl.formatMessage(i18n.certFingerprintHelp)}
</p>
</div>
<div className="bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<p className="text-xs text-amber-800 dark:text-amber-200">
<strong>Note:</strong> {intl.formatMessage(i18n.restartNote)}
+1
View File
@@ -142,6 +142,7 @@ export interface ExternalGoosedConfig {
enabled: boolean;
url?: string;
secret?: string;
certFingerprint?: string;
}
export interface StartGoosedOptions {
+9
View File
@@ -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"
},
+43 -9
View File
@@ -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;
}
+1
View File
@@ -2,6 +2,7 @@ export interface ExternalGoosedConfig {
enabled: boolean;
url: string;
secret: string;
certFingerprint?: string;
}
export interface KeyboardShortcuts {