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:
@@ -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)}
|
||||
|
||||
@@ -142,6 +142,7 @@ export interface ExternalGoosedConfig {
|
||||
enabled: boolean;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
certFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface StartGoosedOptions {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface ExternalGoosedConfig {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
secret: string;
|
||||
certFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcuts {
|
||||
|
||||
Reference in New Issue
Block a user