diff --git a/packages/electron/README.md b/packages/electron/README.md index abda712a..a7763d48 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -159,7 +159,9 @@ Use an explicit override when testing a different OpenCode CLI build or when a u grants permission requests by default when no handler is set, and the panel loads whatever address the user types. Tab favicons are fetched in this session too, so icons behind the page's own login resolve and the app's origin - never requests anything from a third-party host. + never requests anything from a third-party host. Self-signed loopback HTTPS + pages may use an untrusted certificate authority; certificate failures for + external hosts and all other certificate errors remain blocked. ## IPC Pattern diff --git a/packages/electron/browser-panel-security.mjs b/packages/electron/browser-panel-security.mjs new file mode 100644 index 00000000..532148d2 --- /dev/null +++ b/packages/electron/browser-panel-security.mjs @@ -0,0 +1,12 @@ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +export const shouldAllowBrowserPanelCertificateError = ({ url, error }) => { + if (error !== 'net::ERR_CERT_AUTHORITY_INVALID') return false; + + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' && LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase()); + } catch { + return false; + } +}; diff --git a/packages/electron/browser-panel-security.test.mjs b/packages/electron/browser-panel-security.test.mjs new file mode 100644 index 00000000..a81a9e87 --- /dev/null +++ b/packages/electron/browser-panel-security.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; + +test('allows untrusted certificate authorities for loopback HTTPS pages', () => { + for (const url of [ + 'https://localhost:58580/', + 'https://127.0.0.1:58580/', + 'https://[::1]:58580/', + ]) { + assert.equal(shouldAllowBrowserPanelCertificateError({ + url, + error: 'net::ERR_CERT_AUTHORITY_INVALID', + }), true); + } +}); + +test('keeps certificate validation for non-loopback pages', () => { + for (const url of [ + 'https://example.com/', + 'https://localhost.example.com/', + 'https://0.0.0.0:58580/', + ]) { + assert.equal(shouldAllowBrowserPanelCertificateError({ + url, + error: 'net::ERR_CERT_AUTHORITY_INVALID', + }), false); + } +}); + +test('does not bypass other certificate failures or malformed URLs', () => { + assert.equal(shouldAllowBrowserPanelCertificateError({ + url: 'https://localhost:58580/', + error: 'net::ERR_CERT_DATE_INVALID', + }), false); + assert.equal(shouldAllowBrowserPanelCertificateError({ + url: 'not a url', + error: 'net::ERR_CERT_AUTHORITY_INVALID', + }), false); +}); diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 0bcc1de9..c6fe8edb 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -31,6 +31,7 @@ import { setLinuxAutostartEnabled, } from './linux-autostart.mjs'; import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs'; +import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -1186,6 +1187,15 @@ const resolveBrowserPanelContents = (rawId) => { const hardenBrowserPanelSession = () => { const panelSession = session.fromPartition(BROWSER_PANEL_PARTITION); + app.on('certificate-error', (event, contents, url, error, _certificate, callback) => { + if (contents.session === panelSession && shouldAllowBrowserPanelCertificateError({ url, error })) { + event.preventDefault(); + callback(true); + return; + } + callback(false); + }); + panelSession.setPermissionRequestHandler((_contents, permission, callback, details) => { log.info('[electron] browser panel denied a permission request', { permission,