From 5d01972c3c7e80e339134184e13782cd59dd4b8f Mon Sep 17 00:00:00 2001 From: Dmitrii Selivanov Date: Wed, 21 Oct 2026 13:27:18 +0200 Subject: [PATCH] Protect build tooling from compromised certificate authorities (#1352) Refresh workflow CA trust and add an opt-in filtered Node launcher. Preserve inherited TLS settings in website commands and set the native language for Linux locale tests. Validated with hosted lint, 2351 Jest tests, 112 extension browser tests, 151 userscript browser tests, generated-artifact checks, and successful browser artifact builds. --- .github/workflows/build.yaml | 12 ++ .github/workflows/commentcommands.yml | 18 ++ .github/workflows/weblint.yml | 10 + .../extension-owned-page-locale.e2e.js | 6 +- Website/package.json | 4 +- scripts/with-trusted-node.cjs | 198 ++++++++++++++++++ scripts/with-trusted-node.spec.js | 176 ++++++++++++++++ 7 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 scripts/with-trusted-node.cjs create mode 100644 scripts/with-trusted-node.spec.js diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 840cedd..de06bb4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -23,6 +23,7 @@ on: - "package.json" - "package-lock.json" - "scripts/check-locales*" + - "scripts/with-trusted-node*" - "playwright*.js" - "webpack*.js" pull_request: @@ -47,14 +48,25 @@ on: - "package.json" - "package-lock.json" - "scripts/check-locales*" + - "scripts/with-trusted-node*" - "playwright*.js" - "webpack*.js" jobs: build: runs-on: ubuntu-24.04 + env: + NODE_OPTIONS: --use-openssl-ca + SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt + SSL_CERT_DIR: /etc/ssl/certs steps: + - name: Update trusted certificate authorities + run: | + sudo apt-get update -o APT::Update::Error-Mode=any + sudo apt-get install --yes --no-install-recommends --only-upgrade ca-certificates + dpkg --compare-versions "$(dpkg-query -W -f='${Version}' ca-certificates)" ge 20260816ubuntu0.24.04.1 + - name: Checkout repository uses: actions/checkout@v6 diff --git a/.github/workflows/commentcommands.yml b/.github/workflows/commentcommands.yml index 8ac13d2..0cc5f7f 100644 --- a/.github/workflows/commentcommands.yml +++ b/.github/workflows/commentcommands.yml @@ -11,6 +11,12 @@ jobs: contains(github.event.comment.body, '/assignme') || contains(github.event.comment.body, '/assign me') steps: + - name: Update trusted certificate authorities + run: | + sudo apt-get update -o APT::Update::Error-Mode=any + sudo apt-get install --yes --no-install-recommends --only-upgrade ca-certificates + dpkg --compare-versions "$(dpkg-query -W -f='${Version}' ca-certificates)" ge 20260816ubuntu0.24.04.1 + - name: Assigning commenter run: | curl \ @@ -29,6 +35,12 @@ jobs: github.event.comment.author_association == 'CONTRIBUTOR' ) steps: + - name: Update trusted certificate authorities + run: | + sudo apt-get update -o APT::Update::Error-Mode=any + sudo apt-get install --yes --no-install-recommends --only-upgrade ca-certificates + dpkg --compare-versions "$(dpkg-query -W -f='${Version}' ca-certificates)" ge 20260816ubuntu0.24.04.1 + - name: Request issue framing improvement run: | curl \ @@ -47,6 +59,12 @@ jobs: github.event.comment.author_association != 'CONTRIBUTOR' ) steps: + - name: Update trusted certificate authorities + run: | + sudo apt-get update -o APT::Update::Error-Mode=any + sudo apt-get install --yes --no-install-recommends --only-upgrade ca-certificates + dpkg --compare-versions "$(dpkg-query -W -f='${Version}' ca-certificates)" ge 20260816ubuntu0.24.04.1 + - name: Add duplicate label run: | curl \ diff --git a/.github/workflows/weblint.yml b/.github/workflows/weblint.yml index 3e50375..a6d630d 100644 --- a/.github/workflows/weblint.yml +++ b/.github/workflows/weblint.yml @@ -23,8 +23,18 @@ on: jobs: weblint: runs-on: ubuntu-24.04 + env: + NODE_OPTIONS: --use-openssl-ca + SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt + SSL_CERT_DIR: /etc/ssl/certs steps: + - name: Update trusted certificate authorities + run: | + sudo apt-get update -o APT::Update::Error-Mode=any + sudo apt-get install --yes --no-install-recommends --only-upgrade ca-certificates + dpkg --compare-versions "$(dpkg-query -W -f='${Version}' ca-certificates)" ge 20260816ubuntu0.24.04.1 + - name: Checkout repository uses: actions/checkout@v6 diff --git a/Extensions/e2e/extension/extension-owned-page-locale.e2e.js b/Extensions/e2e/extension/extension-owned-page-locale.e2e.js index 039e9b7..155c686 100644 --- a/Extensions/e2e/extension/extension-owned-page-locale.e2e.js +++ b/Extensions/e2e/extension/extension-owned-page-locale.e2e.js @@ -100,7 +100,11 @@ for (const { locale, language, direction } of [ const apiServer = await startHermeticApiServer(); const browserType = { launchPersistentContext: (profile, options) => - chromium.launchPersistentContext(profile, { ...options, args: [...options.args, `--lang=${language}`] }), + chromium.launchPersistentContext(profile, { + ...options, + args: [...options.args, `--lang=${language}`], + ...(process.platform === "linux" ? { env: { ...process.env, ...options.env, LANGUAGE: language } } : {}), + }), }; const adapter = new HermeticExtensionArtifactAdapter({ apiServer, diff --git a/Website/package.json b/Website/package.json index b47eb58..045f20a 100644 --- a/Website/package.json +++ b/Website/package.json @@ -3,10 +3,10 @@ "version": "1.1.0", "private": true, "scripts": { - "dev": "set NODE_OPTIONS=--openssl-legacy-provider && nuxt", + "dev": "node --openssl-legacy-provider node_modules/nuxt/bin/nuxt.js", "build": "nuxt build", "start": "nuxt start", - "generate": "set NODE_OPTIONS=--openssl-legacy-provider && nuxt generate", + "generate": "node --openssl-legacy-provider node_modules/nuxt/bin/nuxt.js generate", "lint": "eslint --fix --ext .js,.vue --ignore-path .eslintignore ." }, "dependencies": { diff --git a/scripts/with-trusted-node.cjs b/scripts/with-trusted-node.cjs new file mode 100644 index 0000000..e8b3914 --- /dev/null +++ b/scripts/with-trusted-node.cjs @@ -0,0 +1,198 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const tls = require("node:tls"); +const { createHash, X509Certificate } = require("node:crypto"); +const { spawnSync } = require("node:child_process"); + +// Temporary root exclusion until Node releases the intermediate-key blocklist: +// https://github.com/nodejs/node/pull/66871 +// https://emudhra.we-have-a-problem.com/roots.json +// https://emudhra.we-have-a-problem.com/impacted-intermediates.json +const BLOCKED_KEYS = new Set([ + "c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9", + "0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282", + "bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3", + "8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782", + "ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105", + "43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b", + "e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41", + "49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f", + "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8", + "5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0", + "ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6", + "92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30", +]); + +function filterCertificates(pems) { + const certificates = new Map(); + for (const pem of pems) { + const cert = new X509Certificate(pem); + const keyHash = createHash("sha256") + .update(cert.publicKey.export({ type: "spki", format: "der" })) + .digest("hex"); + if (!BLOCKED_KEYS.has(keyHash)) certificates.set(cert.fingerprint256, cert.toString()); + } + if (!certificates.size) throw new Error("No trusted certificates remain after filtering."); + return [...certificates.values()].join("\n"); +} + +function readCertificates(filename) { + const source = fs.readFileSync(filename, "utf8"); + const certificates = source.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || []; + if (!certificates.length || certificates.length !== (source.match(/-----BEGIN CERTIFICATE-----/g) || []).length) { + throw new Error("A custom CA file contains missing or incomplete PEM certificates."); + } + return certificates; +} + +function assertSupportedOptions(options = "") { + if ( + /--(?:no[-_])?(?:use[-_](?:bundled|system|openssl)[-_]ca|openssl[-_](?:config|shared[-_]config)|env[-_]file(?:[-_]if[-_]exists)?|experimental[-_](?:default[-_])?config[-_]file)\b/.test( + options, + ) + ) { + throw new Error("Existing CA-store or configuration-loading options require review before using this wrapper."); + } +} + +function prepareEnvironment(directory, environment = process.env, roots = tls.rootCertificates, npmConfig) { + const env = { ...environment }; + // Normalize controlled names, including npm's lowercase Windows environment entries. + for (const key of Object.keys(env)) { + const normalized = key.toUpperCase(); + if ( + [ + "NODE_OPTIONS", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_USE_SYSTEM_CA", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "OPENSSL_CONF", + ].includes(normalized) + ) { + delete env[key]; + env[normalized] = environment[key]; + } + } + assertSupportedOptions(env.NODE_OPTIONS); + if (env.SSL_CERT_FILE || env.SSL_CERT_DIR || env.OPENSSL_CONF || env.NODE_USE_SYSTEM_CA) { + throw new Error("Existing system/OpenSSL CA overrides require review before using this wrapper."); + } + if (env.NODE_TLS_REJECT_UNAUTHORIZED === "0") throw new Error("TLS verification must remain enabled."); + const certificates = [...roots]; + if (env.NODE_EXTRA_CA_CERTS) certificates.push(...readCertificates(env.NODE_EXTRA_CA_CERTS)); + const bundle = path.join(directory, "ca.pem"); + fs.writeFileSync(bundle, filterCertificates(certificates), { flag: "wx" }); + fs.mkdirSync(path.join(directory, "empty")); + env.SSL_CERT_FILE = bundle; + env.SSL_CERT_DIR = path.join(directory, "empty"); + env.NODE_OPTIONS = `${env.NODE_OPTIONS || ""} --use-openssl-ca`.trim(); + delete env.NODE_EXTRA_CA_CERTS; + if (npmConfig) { + if (npmConfig.ca !== null || npmConfig.strictSsl !== true) { + throw new Error("npm inline CA overrides or disabled TLS verification require review before using this wrapper."); + } + assertSupportedOptions(npmConfig.nodeOptions || ""); + for (const key of Object.keys(env)) { + if (/^npm_config_(ca|cafile|strict_ssl|node_options)$/i.test(key)) delete env[key]; + } + env.npm_config_strict_ssl = "true"; + env.npm_config_node_options = npmConfig.nodeOptions + ? `${npmConfig.nodeOptions} --use-openssl-ca` + : env.NODE_OPTIONS; + if (npmConfig.cafile) { + const npmBundle = path.join(directory, "npm-ca.pem"); + fs.writeFileSync(npmBundle, filterCertificates(readCertificates(npmConfig.cafile)), { flag: "wx" }); + env.npm_config_cafile = npmBundle; + } + } + return env; +} + +function findNpmCli() { + const candidates = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"), + path.resolve(path.dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js"), + ]; + const cli = candidates.find((candidate) => candidate && fs.existsSync(candidate)); + if (!cli) throw new Error("Cannot find the npm CLI alongside the current Node installation."); + return cli; +} + +function readNpmConfig(cli) { + const config = {}; + for (const [name, key] of [ + ["ca", "ca"], + ["cafile", "cafile"], + ["strict-ssl", "strictSsl"], + ["node-options", "nodeOptions"], + ]) { + const result = spawnSync(process.execPath, [cli, "config", "get", name, "--no-update-notifier"], { + encoding: "utf8", + }); + if (result.error || result.status !== 0) throw new Error("Could not inspect npm's existing TLS configuration."); + const value = result.stdout.trim(); + config[key] = name === "strict-ssl" ? value === "true" : value === "null" ? null : value; + } + return config; +} + +function main(argv = process.argv.slice(2)) { + const [command, ...args] = argv; + if (!["node", "npm"].includes(command)) { + throw new Error("Usage: node scripts/with-trusted-node.cjs [...arguments]"); + } + assertSupportedOptions( + [...process.execArgv, ...args] + .filter((arg) => arg.startsWith("--")) + .map((arg) => arg.split("=")[0]) + .join(" "), + ); + if (command === "npm") { + const options = args.slice(0, args.includes("--") ? args.indexOf("--") : args.length); + const supported = new Set([ + "--version", + "-v", + "--no-audit", + "--no-fund", + "--ignore-scripts", + "--offline", + "--prefer-offline", + "--no-update-notifier", + "--silent", + ]); + if (options.some((arg) => arg.startsWith("-") && !supported.has(arg))) { + throw new Error( + "Unsupported npm option. Select the project directory and TLS settings before invoking this wrapper.", + ); + } + } + const cli = command === "npm" ? findNpmCli() : null; + const npmConfig = cli ? readNpmConfig(cli) : null; + const privateRoot = path.resolve(__dirname, "../.private/node-trust"); + fs.mkdirSync(privateRoot, { recursive: true }); + const directory = fs.mkdtempSync(path.join(privateRoot, "run-")); + try { + const env = prepareEnvironment(directory, process.env, tls.rootCertificates, npmConfig); + const result = spawnSync(process.execPath, cli ? [cli, ...args] : args, { env, stdio: "inherit" }); + if (result.error) throw result.error; + return result.status === null ? 1 : result.status; + } finally { + const resolved = fs.realpathSync(directory); + if (path.dirname(resolved) !== fs.realpathSync(privateRoot)) throw new Error("Unexpected temporary CA location."); + fs.rmSync(resolved, { recursive: true, force: true }); + } +} + +module.exports = { filterCertificates, readCertificates, prepareEnvironment, main }; + +if (require.main === module) { + try { + process.exitCode = main(); + } catch (error) { + console.error(`Protected Node command failed: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/with-trusted-node.spec.js b/scripts/with-trusted-node.spec.js new file mode 100644 index 0000000..bfe80db --- /dev/null +++ b/scripts/with-trusted-node.spec.js @@ -0,0 +1,176 @@ +/** @jest-environment node */ +const fs = require("node:fs"); +const path = require("node:path"); +const tls = require("node:tls"); +const { X509Certificate } = require("node:crypto"); +const { spawnSync } = require("node:child_process"); +const { filterCertificates, readCertificates, prepareEnvironment, main } = require("./with-trusted-node.cjs"); + +const root = path.resolve(__dirname, "../.private/node-trust-tests"); +const blocked = tls.rootCertificates.find((pem) => + new X509Certificate(pem).subject.includes("CN=AAA Certificate Services"), +); +const allowed = tls.rootCertificates.filter( + (pem) => !new X509Certificate(pem).subject.includes("emSign") && pem !== blocked, +); +const npmConfig = { ca: null, cafile: null, strictSsl: true, nodeOptions: null }; +let temporary; + +beforeEach(() => { + fs.mkdirSync(root, { recursive: true }); + temporary = fs.mkdtempSync(path.join(root, "run-")); +}); + +afterEach(() => { + const resolved = fs.realpathSync(temporary); + expect(path.dirname(resolved)).toBe(fs.realpathSync(root)); + fs.rmSync(resolved, { recursive: true, force: true }); +}); + +test("excludes an affected root by public key and retains unrelated roots without duplicates", () => { + expect(blocked).toBeDefined(); + const result = filterCertificates([allowed[0], blocked, allowed[0]]); + expect(result).toBe(new X509Certificate(allowed[0]).toString()); + expect(() => filterCertificates([blocked])).toThrow(/No trusted certificates/); + expect(() => filterCertificates(["invalid"])).toThrow(); +}); + +test("preserves extra custom certificates absent from the default set and leaves the parent environment unchanged", () => { + const extra = path.join(temporary, "extra.pem"); + fs.writeFileSync(extra, [allowed[1], blocked].join("\n")); + const original = { NODE_EXTRA_CA_CERTS: extra, NODE_OPTIONS: "--max-old-space-size=512", UNRELATED: "preserved" }; + const env = prepareEnvironment(temporary, original, [allowed[0]]); + expect(readCertificates(env.SSL_CERT_FILE)).toHaveLength(2); + expect(fs.readFileSync(env.SSL_CERT_FILE, "utf8")).toContain(new X509Certificate(allowed[1]).toString().trim()); + expect(fs.readFileSync(env.SSL_CERT_FILE, "utf8")).not.toContain(new X509Certificate(blocked).toString().trim()); + expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined(); + expect(env.NODE_OPTIONS).toBe("--max-old-space-size=512 --use-openssl-ca"); + expect(env.UNRELATED).toBe("preserved"); + expect(fs.readdirSync(env.SSL_CERT_DIR)).toEqual([]); + expect(original).toEqual({ + NODE_EXTRA_CA_CERTS: extra, + NODE_OPTIONS: "--max-old-space-size=512", + UNRELATED: "preserved", + }); +}); + +test("filters npm-specific custom trust without adding it to other Node clients", () => { + const cafile = path.join(temporary, "custom.pem"); + fs.writeFileSync(cafile, [allowed[1], blocked].join("\n")); + const env = prepareEnvironment(temporary, {}, [allowed[0]], { + ...npmConfig, + cafile, + nodeOptions: "--max-old-space-size=512", + }); + expect(fs.readFileSync(env.npm_config_cafile, "utf8")).toBe(new X509Certificate(allowed[1]).toString()); + expect(fs.readFileSync(env.SSL_CERT_FILE, "utf8")).not.toContain(new X509Certificate(allowed[1]).toString().trim()); + expect(env.npm_config_node_options).toBe("--max-old-space-size=512 --use-openssl-ca"); + expect(env.npm_config_strict_ssl).toBe("true"); +}); + +test.each([ + { NODE_OPTIONS: "--use-system-ca" }, + { NODE_OPTIONS: "--use_openssl_ca" }, + { NODE_OPTIONS: "--use-bundled-ca" }, + { NODE_OPTIONS: "--no-use-openssl-ca" }, + { NODE_OPTIONS: "--no_use_openssl_ca" }, + { NODE_OPTIONS: "--experimental-config-file=existing.json" }, + { NODE_OPTIONS: "--openssl-config=config.cnf" }, + { SSL_CERT_FILE: "existing.pem" }, + { SSL_CERT_DIR: "existing-directory" }, + { OPENSSL_CONF: "existing.cnf" }, + { NODE_USE_SYSTEM_CA: "1" }, + { NODE_TLS_REJECT_UNAUTHORIZED: "0" }, +])("rejects unsupported trust overrides %p", (env) => { + expect(() => prepareEnvironment(temporary, env, [allowed[0]])).toThrow(); +}); + +test.each([ + { ...npmConfig, strictSsl: false }, + { ...npmConfig, ca: "inline" }, +])("rejects unsupported npm TLS configuration %p", (config) => + expect(() => prepareEnvironment(temporary, {}, [allowed[0]], config)).toThrow(), +); + +test.each(["--no-use-openssl-ca", "--no-use_openssl_ca"])("rejects npm lifecycle trust overrides %s", (nodeOptions) => { + expect(() => prepareEnvironment(temporary, {}, [allowed[0]], { ...npmConfig, nodeOptions })).toThrow(); +}); + +test.each([ + "--no-use-openssl-ca", + "--no-use_openssl_ca", + "--env-file=existing.env", + "--env-file-if-exists=existing.env", + "--experimental-config-file=existing.json", + "--experimental-default-config-file", +])("rejects child Node trust/configuration overrides %s", (option) => { + expect(() => main(["node", option, "-e", "process.exit(0)"])).toThrow(/CA-store/); +}); + +test.each(["--use-openssl-ca", "--no-use-openssl-ca", "--no-use_openssl_ca"])( + "rejects trust overrides on the wrapper's own Node process %s", + (option) => { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (/^(NODE_OPTIONS|SSL_CERT_FILE|SSL_CERT_DIR|NODE_EXTRA_CA_CERTS)$/i.test(key)) delete env[key]; + } + const result = spawnSync( + process.execPath, + [option, path.join(__dirname, "with-trusted-node.cjs"), "node", "-e", "process.exit(0)"], + { env, encoding: "utf8" }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("CA-store"); + }, +); + +test("fails on incomplete custom bundles rather than silently dropping custom trust", () => { + const filename = path.join(temporary, "broken.pem"); + fs.writeFileSync(filename, `${allowed[0]}\n-----BEGIN CERTIFICATE-----\nbroken`); + expect(() => readCertificates(filename)).toThrow(/incomplete/); +}); + +test.each([ + "--prefix=Website", + "--userconfig=other.npmrc", + "-g", + "--cafile=other.pem", + "--ca=inline", + "--pref=Website", +])("rejects npm configuration-selection overrides %s before inspecting npm", (option) => + expect(() => main(["npm", option, "test"])).toThrow(/Unsupported npm option/), +); + +test("applies the replacement store in a native Node child and propagates its exit status", () => { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (/^(NODE_OPTIONS|SSL_CERT_FILE|SSL_CERT_DIR|NODE_EXTRA_CA_CERTS)$/i.test(key)) delete env[key]; + } + const result = spawnSync( + process.execPath, + [path.join(__dirname, "with-trusted-node.cjs"), "node", "-e", "require('https').createServer(); process.exit(17)"], + { + env, + encoding: "utf8", + }, + ); + expect(result.stderr).toBe(""); + expect(result.status).toBe(17); +}); + +test("reads real npm custom CA paths with spaces and lifecycle options without treating them as JSON", () => { + const cafile = path.join(temporary, "custom CA.pem"); + fs.writeFileSync(cafile, [allowed[0], blocked].join("\n")); + const env = { ...process.env, npm_config_cafile: cafile, npm_config_node_options: "--max-old-space-size=256" }; + for (const key of Object.keys(env)) { + if (/^(NODE_OPTIONS|SSL_CERT_FILE|SSL_CERT_DIR|NODE_EXTRA_CA_CERTS)$/i.test(key)) delete env[key]; + } + const result = spawnSync( + process.execPath, + [path.join(__dirname, "with-trusted-node.cjs"), "npm", "--no-update-notifier", "--version"], + { env, encoding: "utf8" }, + ); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); +});