From b06c33383b3404944ff671c5a19f5df8a80458c1 Mon Sep 17 00:00:00 2001 From: chh <1474479+chenhunghan@users.noreply.github.com> Date: Tue, 2 Nov 2021 18:16:10 +0200 Subject: [PATCH] Prevent injection of expired "DST Root CA X3" from host CA stores; Electron to 13.6 (#4185) --- .yarnrc | 2 +- package.json | 2 +- src/common/__tests__/system-ca.test.ts | 113 +++++++++++++++++++++ src/common/system-ca.ts | 95 ++++++++++++++--- src/{main => common/utils}/promise-exec.ts | 0 src/main/helm/helm-chart-manager.ts | 2 +- src/main/helm/helm-release-manager.ts | 2 +- src/main/helm/helm-repo-manager.ts | 2 +- src/main/index.ts | 8 +- src/main/kubectl.ts | 2 +- src/main/resource-applier.ts | 2 +- src/renderer/lens-app.tsx | 4 +- types/mocks.d.ts | 1 + yarn.lock | 8 +- 14 files changed, 216 insertions(+), 27 deletions(-) create mode 100644 src/common/__tests__/system-ca.test.ts rename src/{main => common/utils}/promise-exec.ts (100%) diff --git a/.yarnrc b/.yarnrc index c7a54a1eda..756f21586a 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,3 +1,3 @@ disturl "https://atom.io/download/electron" -target "13.5.1" +target "13.6.1" runtime "electron" diff --git a/package.json b/package.json index f148e5ab3c..ccf31aa815 100644 --- a/package.json +++ b/package.json @@ -318,7 +318,7 @@ "css-loader": "^5.2.7", "deepdash": "^5.3.9", "dompurify": "^2.3.3", - "electron": "^13.5.1", + "electron": "^13.6.1", "electron-builder": "^22.11.11", "electron-notarize": "^0.3.0", "esbuild": "^0.13.8", diff --git a/src/common/__tests__/system-ca.test.ts b/src/common/__tests__/system-ca.test.ts new file mode 100644 index 0000000000..d1fd836c17 --- /dev/null +++ b/src/common/__tests__/system-ca.test.ts @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import https from "https"; +import os from "os"; +import { getMacRootCA, getWinRootCA, injectCAs, DSTRootCAX3 } from "../system-ca"; +import { dependencies, devDependencies } from "../../../package.json"; + +const deps = { ...dependencies, ...devDependencies }; + +// Skip the test if mac-ca is not installed, or os is not darwin +(deps["mac-ca"] && os.platform().includes("darwin") ? describe: describe.skip)("inject CA for Mac", () => { + // for reset https.globalAgent.options.ca after testing + let _ca: string | Buffer | (string | Buffer)[]; + + beforeEach(() => { + _ca = https.globalAgent.options.ca; + }); + + afterEach(() => { + https.globalAgent.options.ca = _ca; + }); + + /** + * The test to ensure using getMacRootCA + injectCAs injects CAs in the same way as using + * the auto injection (require('mac-ca')) + */ + it("should inject the same ca as mac-ca", async () => { + const osxCAs = await getMacRootCA(); + + injectCAs(osxCAs); + const injected = https.globalAgent.options.ca as (string | Buffer)[]; + + await import("mac-ca"); + const injectedByMacCA = https.globalAgent.options.ca as (string | Buffer)[]; + + expect(new Set(injected)).toEqual(new Set(injectedByMacCA)); + }); + + it("shouldn't included the expired DST Root CA X3 on Mac", async () => { + const osxCAs = await getMacRootCA(); + + injectCAs(osxCAs); + const injected = https.globalAgent.options.ca; + + expect(injected.includes(DSTRootCAX3)).toBeFalsy(); + }); +}); + +// Skip the test if win-ca is not installed, or os is not win32 +(deps["win-ca"] && os.platform().includes("win32") ? describe: describe.skip)("inject CA for Windows", () => { + // for reset https.globalAgent.options.ca after testing + let _ca: string | Buffer | (string | Buffer)[]; + + beforeEach(() => { + _ca = https.globalAgent.options.ca; + }); + + afterEach(() => { + https.globalAgent.options.ca = _ca; + }); + + /** + * The test to ensure using win-ca/api injects CAs in the same way as using + * the auto injection (require('win-ca').inject('+')) + */ + it("should inject the same ca as winca.inject('+')", async () => { + const winCAs = await getWinRootCA(); + + const wincaAPI = await import("win-ca/api"); + + wincaAPI.inject("+", winCAs); + const injected = https.globalAgent.options.ca as (string | Buffer)[]; + + const winca = await import("win-ca"); + + winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats + const injectedByWinCA = https.globalAgent.options.ca as (string | Buffer)[]; + + expect(new Set(injected)).toEqual(new Set(injectedByWinCA)); + }); + + it("shouldn't included the expired DST Root CA X3 on Windows", async () => { + const winCAs = await getWinRootCA(); + + const wincaAPI = await import("win-ca/api"); + + wincaAPI.inject("true", winCAs); + const injected = https.globalAgent.options.ca as (string | Buffer)[]; + + expect(injected.includes(DSTRootCAX3)).toBeFalsy(); + }); +}); + + + diff --git a/src/common/system-ca.ts b/src/common/system-ca.ts index 7ed0120569..904ec01b97 100644 --- a/src/common/system-ca.ts +++ b/src/common/system-ca.ts @@ -20,22 +20,93 @@ */ import { isMac, isWindows } from "./vars"; -import winca from "win-ca"; -import macca from "mac-ca"; -import logger from "../main/logger"; +import wincaAPI from "win-ca/api"; +import https from "https"; +import { promiseExec } from "./utils/promise-exec"; -if (isMac) { - for (const crt of macca.all()) { - const attributes = crt.issuer?.attributes?.map((a: any) => `${a.name}=${a.value}`); +// DST Root CA X3, which was expired on 9.30.2021 +export const DSTRootCAX3 = "-----BEGIN CERTIFICATE-----\nMIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/\nMSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT\nDkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow\nPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD\nEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\nAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O\nrz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq\nOLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b\nxiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw\n7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD\naeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV\nHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG\nSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69\nikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr\nAvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz\nR8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5\nJDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo\nOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ\n-----END CERTIFICATE-----\n"; - logger.debug(`Using host CA: ${attributes.join(",")}`); +export function isCertActive(cert: string) { + const isExpired = typeof cert !== "string" || cert.includes(DSTRootCAX3); + + return !isExpired; +} + +/** + * Get root CA certificate from MacOSX system keychain + * Only return non-expred certificates. + */ +export async function getMacRootCA() { + // inspired mac-ca https://github.com/jfromaniello/mac-ca + const args = "find-certificate -a -p"; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet#other_assertions + const splitPattern = /(?=-----BEGIN\sCERTIFICATE-----)/g; + const systemRootCertsPath = "/System/Library/Keychains/SystemRootCertificates.keychain"; + const bin = "/usr/bin/security"; + const trusted = (await promiseExec(`${bin} ${args}`)).stdout.toString().split(splitPattern); + const rootCA = (await promiseExec(`${bin} ${args} ${systemRootCertsPath}`)).stdout.toString().split(splitPattern); + + return [...new Set([...trusted, ...rootCA])].filter(isCertActive); +} + +/** + * Get root CA certificate from Windows system certificate store. + * Only return non-expred certificates. + */ +export function getWinRootCA(): Promise { + return new Promise((resolve) => { + const CAs: string[] = []; + + wincaAPI({ + format: wincaAPI.der2.pem, + inject: false, + ondata: (ca: string) => { + CAs.push(ca); + }, + onend: () => { + resolve(CAs.filter(isCertActive)); + } + }); + }); +} + + +/** + * Add (or merge) CAs to https.globalAgent.options.ca + */ +export function injectCAs(CAs: string[]) { + for (const cert of CAs) { + if (Array.isArray(https.globalAgent.options.ca) && !https.globalAgent.options.ca.includes(cert)) { + https.globalAgent.options.ca.push(cert); + } else { + https.globalAgent.options.ca = [cert]; + } } } -if (isWindows) { - try { - winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats - } catch (error) { - logger.error(`[CA]: failed to force load: ${error}`); +/** + * Inject CAs found in OS's (Windoes/MacOSX only) root certificate store to https.globalAgent.options.ca + */ +export async function injectSystemCAs() { + if (isMac) { + try { + const osxRootCAs = await getMacRootCA(); + + injectCAs(osxRootCAs); + } catch (error) { + console.warn(`[MAC-CA]: Error injecting root CAs from MacOSX. ${error?.message}`); + } + } + + if (isWindows) { + try { + const winRootCAs = await getWinRootCA(); + + wincaAPI.inject("+", winRootCAs); + + } catch (error) { + console.warn(`[WIN-CA]: Error injecting root CAs from Windows. ${error?.message}`); + } } } diff --git a/src/main/promise-exec.ts b/src/common/utils/promise-exec.ts similarity index 100% rename from src/main/promise-exec.ts rename to src/common/utils/promise-exec.ts diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index b7afb6ff3d..6f2b0ee7e5 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -24,7 +24,7 @@ import v8 from "v8"; import * as yaml from "js-yaml"; import type { HelmRepo } from "./helm-repo-manager"; import logger from "../logger"; -import { promiseExecFile } from "../promise-exec"; +import { promiseExecFile } from "../../common/utils/promise-exec"; import { helmCli } from "./helm-cli"; import type { RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-charts.api"; import { iter, sortCharts } from "../../common/utils"; diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index 1f989c6428..9a15919e35 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -22,7 +22,7 @@ import * as tempy from "tempy"; import fse from "fs-extra"; import * as yaml from "js-yaml"; -import { promiseExec } from "../promise-exec"; +import { promiseExec } from "../../common/utils/promise-exec"; import { helmCli } from "./helm-cli"; import type { Cluster } from "../cluster"; import { toCamelCase } from "../../common/utils/camelCase"; diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index 2da7cfff18..3b424de042 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -21,7 +21,7 @@ import yaml from "js-yaml"; import { readFile } from "fs-extra"; -import { promiseExec } from "../promise-exec"; +import { promiseExec } from "../../common/utils/promise-exec"; import { helmCli } from "./helm-cli"; import { Singleton } from "../../common/utils/singleton"; import { customRequestPromise } from "../../common/request"; diff --git a/src/main/index.ts b/src/main/index.ts index 786a79a7b7..4315679fec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -21,7 +21,7 @@ // Main process -import "../common/system-ca"; +import { injectSystemCAs } from "../common/system-ca"; import { initialize as initializeRemote } from "@electron/remote/main"; import * as Mobx from "mobx"; import * as LensExtensionsCommonApi from "../extensions/common-api"; @@ -66,13 +66,15 @@ import { initTray } from "./tray"; import { kubeApiRequest, shellApiRequest } from "./proxy-functions"; import { AppPaths } from "../common/app-paths"; +injectSystemCAs(); + const onCloseCleanup = disposer(); const onQuitCleanup = disposer(); - SentryInit(); app.setName(appName); + logger.info(`📟 Setting ${productName} as protocol client for lens://`); if (app.setAsDefaultProtocolClient("lens")) { @@ -120,7 +122,7 @@ app.on("second-instance", (event, argv) => { WindowManager.getInstance(false)?.ensureMainWindow(); }); -app.on("ready", async () => { +app.on("ready", async () => { logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`); logger.info("🐚 Syncing shell environment"); await shellSync(); diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index 9454ec33fd..80cecbf89b 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -21,7 +21,7 @@ import path from "path"; import fs from "fs"; -import { promiseExec } from "./promise-exec"; +import { promiseExec } from "../common/utils/promise-exec"; import logger from "./logger"; import { ensureDir, pathExists } from "fs-extra"; import * as lockFile from "proper-lockfile"; diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index e6200b9d30..38e5af1df2 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -30,7 +30,7 @@ import logger from "./logger"; import { appEventBus } from "../common/event-bus"; import { cloneJsonObject } from "../common/utils"; import type { Patch } from "rfc6902"; -import { promiseExecFile } from "./promise-exec"; +import { promiseExecFile } from "../common/utils/promise-exec"; export class ResourceApplier { constructor(protected cluster: Cluster) {} diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index 18be54e8e0..7d9e41a8b6 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "../common/system-ca"; +import { injectSystemCAs } from "../common/system-ca"; import React from "react"; import { Route, Router, Switch } from "react-router"; import { observer } from "mobx-react"; @@ -39,6 +39,8 @@ import { catalogEntityRegistry } from "./api/catalog-entity-registry"; import logger from "../common/logger"; import { unmountComponentAtNode } from "react-dom"; +injectSystemCAs(); + @observer export class LensApp extends React.Component { static async init(rootElem: HTMLElement) { diff --git a/types/mocks.d.ts b/types/mocks.d.ts index 4c013fc930..57e2812dcb 100644 --- a/types/mocks.d.ts +++ b/types/mocks.d.ts @@ -20,6 +20,7 @@ */ declare module "mac-ca" declare module "win-ca" +declare module "win-ca/api" declare module "@hapi/call" declare module "@hapi/subtext" diff --git a/yarn.lock b/yarn.lock index 8abdce9464..0d4d182483 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5031,10 +5031,10 @@ electron-window-state@^5.0.3: jsonfile "^4.0.0" mkdirp "^0.5.1" -electron@^13.5.1: - version "13.5.1" - resolved "https://registry.yarnpkg.com/electron/-/electron-13.5.1.tgz#76c02c39be228532f886a170b472cbd3d93f0d0f" - integrity sha512-ZyxhIhmdaeE3xiIGObf0zqEyCyuIDqZQBv9NKX8w5FNzGm87j4qR0H1+GQg6vz+cA1Nnv1x175Zvimzc0/UwEQ== +electron@^13.6.1: + version "13.6.1" + resolved "https://registry.yarnpkg.com/electron/-/electron-13.6.1.tgz#f61c4f269b57c7dc27e0d5476216a988caa9c752" + integrity sha512-rZ6Y7RberigruefQpWOiI4bA9ppyT88GQF8htY6N1MrAgal5RrBc+Mh92CcGU7zT9QO+XO3DarSgZafNTepffQ== dependencies: "@electron/get" "^1.0.1" "@types/node" "^14.6.2"