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+$/); +});