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.
This commit is contained in:
Dmitrii Selivanov
2026-10-21 13:27:18 +02:00
committed by GitHub
parent 94065a43d9
commit 5d01972c3c
7 changed files with 421 additions and 3 deletions
+12
View File
@@ -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
+18
View File
@@ -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 \
+10
View File
@@ -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
@@ -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,
+2 -2
View File
@@ -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": {
+198
View File
@@ -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 <node|npm> [...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;
}
}
+176
View File
@@ -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+$/);
});