From 0c5e183c62718a638a6e5cc63a3e30452395c55e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 18 Aug 2026 23:10:14 +0300 Subject: [PATCH] fix(electron): load self-signed loopback pages --- packages/electron/README.md | 4 +- packages/electron/browser-panel-security.mjs | 12 ++++++ .../electron/browser-panel-security.test.mjs | 41 +++++++++++++++++++ packages/electron/main.mjs | 10 +++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/electron/browser-panel-security.mjs create mode 100644 packages/electron/browser-panel-security.test.mjs 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,