From 965bb5f63421d04ed4b44dfde3b58490177b8788 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 17:26:12 +0200 Subject: [PATCH] fix(desktop): trust the system CA store in main requests (#3281) --- .../src/main/DesktopOutboundHTTP.ts | 17 +++- .../main/DesktopTrustedCertificates.test.mjs | 94 +++++++++++++++++++ .../src/main/DesktopTrustedCertificates.ts | 27 ++++++ .../src/main/FileDownloads.test.mjs | 1 + .../src/main/fixtures/desktop-tls/leaf.key | 5 + .../src/main/fixtures/desktop-tls/leaf.pem | 12 +++ .../main/fixtures/desktop-tls/trusted-ca.pem | 11 +++ .../fixtures/desktop-tls/untrusted-ca.pem | 12 +++ 8 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 fluxer_desktop/src/main/DesktopTrustedCertificates.test.mjs create mode 100644 fluxer_desktop/src/main/DesktopTrustedCertificates.ts create mode 100644 fluxer_desktop/src/main/fixtures/desktop-tls/leaf.key create mode 100644 fluxer_desktop/src/main/fixtures/desktop-tls/leaf.pem create mode 100644 fluxer_desktop/src/main/fixtures/desktop-tls/trusted-ca.pem create mode 100644 fluxer_desktop/src/main/fixtures/desktop-tls/untrusted-ca.pem diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts index 40380b6ed..bf50f63b6 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts @@ -15,6 +15,7 @@ import { resolveDesktopSessionProxy, sendThroughDesktopSession, } from '@electron/main/DesktopSessionHTTP'; +import {resolveDesktopTrustedCertificates} from '@electron/main/DesktopTrustedCertificates'; import {normalizeHTTPNetworkOrigin} from '@fluxer/instance_bootstrap/src/NetworkOrigin'; const logger = createChildLogger('DesktopOutboundHTTP'); @@ -135,6 +136,7 @@ interface DesktopOutboundHTTPOptions { readonly resolveHostAddresses?: DesktopHostAddressResolver; readonly resolveProxy?: DesktopProxyResolver; readonly sendThroughSession?: DesktopSessionHTTPSender; + readonly trustedCertificates?: ReadonlyArray; } interface DesktopOutboundGETRequest { @@ -725,15 +727,12 @@ interface DesktopProxiedSend { export class DesktopOutboundHTTP { private readonly httpAgent = new http.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS}); - private readonly httpsAgent = new https.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS}); + private readonly httpsAgent: https.Agent; private readonly originRequestHttpAgent = new http.Agent({ keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE, }); - private readonly originRequestHttpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE, - }); + private readonly originRequestHttpsAgent: https.Agent; private readonly bindings = new Map(); private readonly pendingBindings = new Map(); private readonly registeredRequirements = new Map(); @@ -750,6 +749,14 @@ export class DesktopOutboundHTTP { this.resolveHostAddresses = options.resolveHostAddresses ?? lookupAllAddresses; this.resolveProxy = options.resolveProxy ?? resolveDesktopSessionProxy; this.sendThroughSession = options.sendThroughSession ?? sendThroughDesktopSession; + const certificates = options.trustedCertificates ?? resolveDesktopTrustedCertificates(); + const trust = certificates.length > 0 ? {ca: [...certificates]} : {}; + this.httpsAgent = new https.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS, ...trust}); + this.originRequestHttpsAgent = new https.Agent({ + keepAlive: true, + maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE, + ...trust, + }); } public async get(request: DesktopOutboundGETRequest): Promise { diff --git a/fluxer_desktop/src/main/DesktopTrustedCertificates.test.mjs b/fluxer_desktop/src/main/DesktopTrustedCertificates.test.mjs new file mode 100644 index 000000000..aefa9914b --- /dev/null +++ b/fluxer_desktop/src/main/DesktopTrustedCertificates.test.mjs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import {describe, test} from 'node:test'; +import tls from 'node:tls'; +import {fileURLToPath} from 'node:url'; +import {installElectronStub} from './LocalAppTestSupport.test.mjs'; + +installElectronStub(); + +const {resolveDesktopTrustedCertificates} = await import('./DesktopTrustedCertificates.ts'); +const {DesktopOriginTrust, DesktopOutboundHTTP} = await import('./DesktopOutboundHTTP.ts'); + +const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'desktop-tls'); +const read = (name) => fs.readFileSync(path.join(FIXTURES, name), 'utf8'); +const TRUSTED_CA = read('trusted-ca.pem'); +const UNTRUSTED_CA = read('untrusted-ca.pem'); + +async function startTLSServer() { + const server = https.createServer({cert: read('leaf.pem'), key: read('leaf.key')}, (_request, response) => { + response.writeHead(200, {'content-type': 'text/plain'}); + response.end('ok'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return {port: server.address().port, server}; +} + +async function fetchThrough(certificates, port) { + const outboundHTTP = new DesktopOutboundHTTP({ + resolveHostAddresses: async () => ['127.0.0.1'], + trustedCertificates: certificates, + }); + try { + await outboundHTTP.registerAnchoredOrigins({anchorOrigin: `https://ca.example:${port}`, origins: []}); + const response = await outboundHTTP.request({ + body: null, + expectedOrigin: `https://ca.example:${port}`, + headers: null, + method: 'GET', + originTrust: DesktopOriginTrust.REGISTERED, + serviceName: 'test', + signal: null, + timeoutMs: 3000, + url: `https://ca.example:${port}/`, + }); + response.message.resume(); + return response.status; + } finally { + outboundHTTP.cleanup(); + } +} + +describe('desktop trusted certificates', () => { + test('merges the bundled roots with the system store without duplicates', () => { + const merged = resolveDesktopTrustedCertificates((store) => (store === 'default' ? ['a', 'b'] : ['b', 'c'])); + assert.deepEqual(merged, ['a', 'b', 'c']); + }); + + test('keeps the bundled roots when the system store cannot be read', () => { + const merged = resolveDesktopTrustedCertificates((store) => { + if (store === 'system') throw new Error('no system store'); + return ['a']; + }); + assert.deepEqual(merged, ['a']); + }); + + test('reaches an instance whose CA is only trusted by the system store', async () => { + const {port, server} = await startTLSServer(); + try { + const certificates = resolveDesktopTrustedCertificates((store) => + store === 'system' ? [TRUSTED_CA] : tls.getCACertificates('default'), + ); + assert.equal(await fetchThrough(certificates, port), 200); + } finally { + server.close(); + } + }); + + test('still refuses an instance signed by a CA nobody trusts', async () => { + const {port, server} = await startTLSServer(); + try { + const certificates = resolveDesktopTrustedCertificates((store) => + store === 'system' ? [UNTRUSTED_CA] : tls.getCACertificates('default'), + ); + await assert.rejects(fetchThrough(certificates, port)); + await assert.rejects(fetchThrough(tls.getCACertificates('default'), port)); + } finally { + server.close(); + } + }); +}); diff --git a/fluxer_desktop/src/main/DesktopTrustedCertificates.ts b/fluxer_desktop/src/main/DesktopTrustedCertificates.ts new file mode 100644 index 000000000..9bd39e93c --- /dev/null +++ b/fluxer_desktop/src/main/DesktopTrustedCertificates.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import tls from 'node:tls'; +import {createChildLogger} from '@electron/common/Logger'; + +const logger = createChildLogger('DesktopTrustedCertificates'); + +export type DesktopCertificateStore = 'default' | 'system'; +export type DesktopCertificateSource = (store: DesktopCertificateStore) => ReadonlyArray; + +function readStore(source: DesktopCertificateSource, store: DesktopCertificateStore): ReadonlyArray { + try { + return source(store); + } catch (error) { + logger.warn('Could not read a certificate store, continuing without it', {store, error}); + return []; + } +} + +export function resolveDesktopTrustedCertificates( + source: DesktopCertificateSource = (store) => tls.getCACertificates(store), +): ReadonlyArray { + const merged = new Set(); + for (const certificate of readStore(source, 'default')) merged.add(certificate); + for (const certificate of readStore(source, 'system')) merged.add(certificate); + return [...merged]; +} diff --git a/fluxer_desktop/src/main/FileDownloads.test.mjs b/fluxer_desktop/src/main/FileDownloads.test.mjs index 6b045da32..d6a26d1dc 100644 --- a/fluxer_desktop/src/main/FileDownloads.test.mjs +++ b/fluxer_desktop/src/main/FileDownloads.test.mjs @@ -89,6 +89,7 @@ async function loadFileDownloads({registerOrigin = true} = {}) { throw new Error('a direct route never reaches the session'); }, }, + '@electron/main/DesktopTrustedCertificates': {resolveDesktopTrustedCertificates: () => []}, }; const sandbox = { console, diff --git a/fluxer_desktop/src/main/fixtures/desktop-tls/leaf.key b/fluxer_desktop/src/main/fixtures/desktop-tls/leaf.key new file mode 100644 index 000000000..446dc8d4f --- /dev/null +++ b/fluxer_desktop/src/main/fixtures/desktop-tls/leaf.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjRw9RoOHDfdSs087 +7vd6ZmYW1AJE4+6LGqJW4RJfvs+hRANCAAQvRk8uN0d9x8o01QFpd75RxviSJhaZ +nr4iEQRdEGEs+OKic0Cm9nHwBSainqm0kO8YKlT9cq2RI1jK9rqM9hIt +-----END PRIVATE KEY----- diff --git a/fluxer_desktop/src/main/fixtures/desktop-tls/leaf.pem b/fluxer_desktop/src/main/fixtures/desktop-tls/leaf.pem new file mode 100644 index 000000000..afeeb27dd --- /dev/null +++ b/fluxer_desktop/src/main/fixtures/desktop-tls/leaf.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBzDCCAXGgAwIBAgIUFo38/sRgT50k5RgWhjrjAqkyUxwwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWRmx1eGVyIFRlc3QgdHJ1c3RlZCBDQTAgFw0yNjEwMDgxMTAw +NDVaGA8yMTI2MDkxNDExMDA0NVowFTETMBEGA1UEAwwKY2EuZXhhbXBsZTBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABC9GTy43R33HyjTVAWl3vlHG+JImFpmeviIR +BF0QYSz44qJzQKb2cfAFJqKeqbSQ7xgqVP1yrZEjWMr2uoz2Ei2jgZAwgY0wGwYD +VR0RBBQwEoIKY2EuZXhhbXBsZYcEfwAAATAJBgNVHRMEAjAAMA4GA1UdDwEB/wQE +AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUrTpUZ2NXWO1wgiIJ +5SXBE3OlT+MwHwYDVR0jBBgwFoAUekfvEi5SI/1J8dOCNgGBW+TJQGUwCgYIKoZI +zj0EAwIDSQAwRgIhALqUP9Hm71LmvrbIuP9IIrG2efo70QPNL0n3caoCmVO8AiEA +p7IbBwavgmqy6QhAkIgH5BPAQ4n37fP0UyeZVetYFII= +-----END CERTIFICATE----- diff --git a/fluxer_desktop/src/main/fixtures/desktop-tls/trusted-ca.pem b/fluxer_desktop/src/main/fixtures/desktop-tls/trusted-ca.pem new file mode 100644 index 000000000..e7d284ba5 --- /dev/null +++ b/fluxer_desktop/src/main/fixtures/desktop-tls/trusted-ca.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBqTCCAU+gAwIBAgIUM6OwmZlGxvxbg2ReQWwRMOy5H0swCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWRmx1eGVyIFRlc3QgdHJ1c3RlZCBDQTAgFw0yNjEwMDgxMTAw +NDVaGA8yMTI2MDkxNDExMDA0NVowITEfMB0GA1UEAwwWRmx1eGVyIFRlc3QgdHJ1 +c3RlZCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABH+hGFbBocy+br8Lt+lE +9usokqgUdcJEEE8F1DSwzKqtTXYqaNQ1IbnjnYOnMzMP3C8SH+Z+W7h4CCgbeCHo +3FCjYzBhMB0GA1UdDgQWBBR6R+8SLlIj/Unx04I2AYFb5MlAZTAfBgNVHSMEGDAW +gBR6R+8SLlIj/Unx04I2AYFb5MlAZTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAKBggqhkjOPQQDAgNIADBFAiEA9ZiceuAgmWNbt1iGI8pIEKJnEBOK +B2EbyL9bNslsOwcCICmZwV2NHSnLTVtNiPqQDkRskugRL5ywBhddeqApBdk6 +-----END CERTIFICATE----- diff --git a/fluxer_desktop/src/main/fixtures/desktop-tls/untrusted-ca.pem b/fluxer_desktop/src/main/fixtures/desktop-tls/untrusted-ca.pem new file mode 100644 index 000000000..6b9c4206e --- /dev/null +++ b/fluxer_desktop/src/main/fixtures/desktop-tls/untrusted-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBrTCCAVOgAwIBAgIUXHirlkuWFHOEJ+x5lRwAyIeQH0AwCgYIKoZIzj0EAwIw +IzEhMB8GA1UEAwwYRmx1eGVyIFRlc3QgdW50cnVzdGVkIENBMCAXDTI2MTAwODEx +MDA0NVoYDzIxMjYwOTE0MTEwMDQ1WjAjMSEwHwYDVQQDDBhGbHV4ZXIgVGVzdCB1 +bnRydXN0ZWQgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASYetrmC6ot/0o0 +jxSFQRVFuZBzfdGYnOZ41+N2Jao9/6CoxuD+pqjOJ+NAjLP5QSMBuoaYaDKRzq+r +jDlVf+JJo2MwYTAdBgNVHQ4EFgQUi/e+X9VSTPlOrhbO2BAqjCl28OAwHwYDVR0j +BBgwFoAUi/e+X9VSTPlOrhbO2BAqjCl28OAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIhAN9+gjPnixgQHJqspIqJG2x7 +jPmFxBGV9fQiEUM14NzAAiAt4pdRQ+sHc1VHsfEeUUHS2W0+UKoc/XHTjDZtppEH +Xw== +-----END CERTIFICATE-----