From 1b21fe009b99cee3893bac012c9996593cd49704 Mon Sep 17 00:00:00 2001 From: yoshihiro999 <216183434+yoshihiro999@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:44:40 +0900 Subject: [PATCH 1/2] Harden Node archive Starlark generation --- knife | 4 + knife.d/update_node_archives.js | 122 +++++++++++++++++++++------ knife.d/update_node_archives_test.js | 38 +++++++++ 3 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 knife.d/update_node_archives_test.js diff --git a/knife b/knife index f93413624..7cd75750c 100755 --- a/knife +++ b/knife @@ -176,6 +176,10 @@ function cmd_update_node_archives () { | sort_by(.date) | reverse | .[0].version ') latest_nov=$(echo "$latest_version" | sed 's/^v//') + if [[ ! "$latest_nov" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid Node.js version from release index: $latest_version" >&2 + exit 1 + fi versions+=("$latest_nov") done diff --git a/knife.d/update_node_archives.js b/knife.d/update_node_archives.js index fc1403c3e..e8d1caca3 100755 --- a/knife.d/update_node_archives.js +++ b/knife.d/update_node_archives.js @@ -3,16 +3,52 @@ const crypto = require("crypto"); const https = require("https"); const fs = require("fs"); -if (process.argv.length < 3) { - console.error("Usage: node nodeChecksum.js "); - process.exit(1); -} - -const versions = process.argv[2].split(","); +let versions = []; const architectures = ["amd64", "arm64", "arm", "ppc64le", "s390x"]; +const NODE_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/; +const ARCHIVE_SUFFIX_RE = /^(x64|arm64|armv7l|ppc64le|s390x)$/; +const DISTROLESS_ARCH_RE = /^(amd64|arm64|arm|ppc64le|s390x)$/; +const SHA256_RE = /^[0-9a-f]{64}$/; const nodeVersions = {}; +const starlarkString = (value) => { + return `"${String(value) + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t")}"`; +}; + +const validateNodeVersion = (version) => { + if (!NODE_VERSION_RE.test(version)) { + throw new Error(`Invalid Node.js version: ${version}`); + } + return version; +}; + +const validateArchiveSuffix = (suffix) => { + if (!ARCHIVE_SUFFIX_RE.test(suffix)) { + throw new Error(`Invalid Node.js archive suffix: ${suffix}`); + } + return suffix; +}; + +const validateDistrolessArch = (arch) => { + if (!DISTROLESS_ARCH_RE.test(arch)) { + throw new Error(`Invalid distroless architecture: ${arch}`); + } + return arch; +}; + +const validateSha256 = (sha256) => { + if (!SHA256_RE.test(sha256)) { + throw new Error(`Invalid SHA256 checksum: ${sha256}`); + } + return sha256; +}; + const calculateChecksum = (url) => { return new Promise((resolve, reject) => { https @@ -33,6 +69,7 @@ const calculateChecksum = (url) => { const fetchChecksums = async () => { for (const nodeVersion of versions) { + validateNodeVersion(nodeVersion); const major = parseInt(nodeVersion.split(".")[0]); nodeVersions[nodeVersion] = {}; await Promise.all( @@ -46,6 +83,7 @@ const fetchChecksums = async () => { } else if (key === "arm") { arch = "armv7l"; } + validateArchiveSuffix(arch); const url = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-linux-${arch}.tar.gz`; try { const checksum = await calculateChecksum(url); @@ -54,14 +92,38 @@ const fetchChecksums = async () => { suffix: arch, }; } catch (error) { - console.error(error); + throw new Error(`Failed to fetch checksum for ${url}: ${error}`); } }) ); } }; -fetchChecksums().then(() => { +const validateFetchedChecksums = () => { + for (const nodeVersion of versions) { + const major = parseInt(nodeVersion.split(".")[0]); + for (const arch of architectures) { + if (major > 22 && arch === "arm") { + continue; + } + if (!nodeVersions[nodeVersion] || !nodeVersions[nodeVersion][arch]) { + throw new Error( + `Failed to fetch checksum for Node.js version ${nodeVersion} on ${arch}` + ); + } + } + } +}; + +const main = async () => { + if (process.argv.length < 3) { + console.error("Usage: node nodeChecksum.js "); + process.exit(1); + } + versions = process.argv[2].split(","); + await fetchChecksums(); + validateFetchedChecksums(); + let nodeArchives = `"node" BUILD_TMPL = """\\ @@ -172,16 +234,19 @@ def _node_impl(module_ctx): } const arch = nodeVersions[nodeVersion][key]; const url = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-linux-${arch.suffix}.tar.gz`; + validateDistrolessArch(key); + validateArchiveSuffix(arch.suffix); + validateSha256(arch.checksum); nodeArchives += "\n"; nodeArchives += ` node_archive( - name = "nodejs${major}_${key}", - sha256 = "${arch.checksum}", - strip_prefix = "node-v${nodeVersion}-linux-${arch.suffix}/", - urls = ["${url}"], - version = "${nodeVersion}", - architecture = "${key}", + name = ${starlarkString(`nodejs${major}_${key}`)}, + sha256 = ${starlarkString(arch.checksum)}, + strip_prefix = ${starlarkString(`node-v${nodeVersion}-linux-${arch.suffix}/`)}, + urls = [${starlarkString(url)}], + version = ${starlarkString(nodeVersion)}, + architecture = ${starlarkString(key)}, control = "//nodejs:control", )`; } @@ -194,11 +259,7 @@ commandTests: expectedOutput: ["v${nodeVersion}"] `; - fs.writeFile(`nodejs/testdata/nodejs${major}.yaml`, testData, (err) => { - if (err) { - console.error(err); - } - }); + fs.writeFileSync(`nodejs/testdata/nodejs${major}.yaml`, testData); } nodeArchives += ` @@ -212,7 +273,7 @@ commandTests: continue; } nodeArchives += ` - "nodejs${major}_${arch}",`; + ${starlarkString(`nodejs${major}_${arch}`)},`; } } @@ -232,9 +293,20 @@ node = module_extension( `; // Write output to node_archives.bzl file - fs.writeFile("private/extensions/node.bzl", nodeArchives, (err) => { - if (err) { - console.error(err); - } + fs.writeFileSync("private/extensions/node.bzl", nodeArchives); +}; + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exit(1); }); -}); +} + +module.exports = { + starlarkString, + validateArchiveSuffix, + validateDistrolessArch, + validateNodeVersion, + validateSha256, +}; diff --git a/knife.d/update_node_archives_test.js b/knife.d/update_node_archives_test.js new file mode 100644 index 000000000..5cf08c575 --- /dev/null +++ b/knife.d/update_node_archives_test.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +const assert = require("node:assert/strict"); + +const { + starlarkString, + validateArchiveSuffix, + validateDistrolessArch, + validateNodeVersion, + validateSha256, +} = require("./update_node_archives.js"); + +assert.equal(validateNodeVersion("26.2.0"), "26.2.0"); +assert.throws( + () => validateNodeVersion('26.2.0" + "bad'), + /Invalid Node.js version/ +); +assert.throws(() => validateNodeVersion("v26.2.0"), /Invalid Node.js version/); +assert.throws(() => validateNodeVersion("26.2.0\n26.2.1"), /Invalid Node.js version/); + +assert.equal(validateArchiveSuffix("x64"), "x64"); +assert.throws(() => validateArchiveSuffix('x64" + fail("bad") + "'), /Invalid Node.js archive suffix/); + +assert.equal(validateDistrolessArch("amd64"), "amd64"); +assert.throws(() => validateDistrolessArch("amd64\nbad"), /Invalid distroless architecture/); + +assert.equal( + validateSha256("a".repeat(64)), + "a".repeat(64) +); +assert.throws(() => validateSha256("not-a-sha"), /Invalid SHA256 checksum/); + +assert.equal( + starlarkString('26.2.0" + "bad'), + '"26.2.0\\" + \\"bad"' +); +assert.equal(starlarkString("line1\nline2"), '"line1\\nline2"'); + +console.log("update_node_archives validation tests passed"); From bfa40f20224a55cebcf807c3864e459410d521b5 Mon Sep 17 00:00:00 2001 From: yoshihiro999 <216183434+yoshihiro999@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:15:27 +0900 Subject: [PATCH 2/2] Reject non-200 Node archive responses --- knife.d/update_node_archives.js | 6 ++++ knife.d/update_node_archives_test.js | 46 +++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/knife.d/update_node_archives.js b/knife.d/update_node_archives.js index e8d1caca3..af7d3caa9 100755 --- a/knife.d/update_node_archives.js +++ b/knife.d/update_node_archives.js @@ -53,6 +53,11 @@ const calculateChecksum = (url) => { return new Promise((resolve, reject) => { https .get(url, (res) => { + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`Request failed with status code ${res.statusCode}`)); + return; + } const hash = crypto.createHash("sha256"); res.on("data", (data) => { hash.update(data); @@ -304,6 +309,7 @@ if (require.main === module) { } module.exports = { + calculateChecksum, starlarkString, validateArchiveSuffix, validateDistrolessArch, diff --git a/knife.d/update_node_archives_test.js b/knife.d/update_node_archives_test.js index 5cf08c575..2b04185ab 100644 --- a/knife.d/update_node_archives_test.js +++ b/knife.d/update_node_archives_test.js @@ -1,7 +1,10 @@ #!/usr/bin/env node const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const https = require("https"); const { + calculateChecksum, starlarkString, validateArchiveSuffix, validateDistrolessArch, @@ -35,4 +38,45 @@ assert.equal( ); assert.equal(starlarkString("line1\nline2"), '"line1\\nline2"'); -console.log("update_node_archives validation tests passed"); +const withMockedHttpsGet = async (mock, fn) => { + const originalGet = https.get; + https.get = mock; + try { + await fn(); + } finally { + https.get = originalGet; + } +}; + +const mockHttpsGetStatus = (statusCode) => (_url, onResponse) => { + const req = new EventEmitter(); + process.nextTick(() => { + const res = new EventEmitter(); + res.statusCode = statusCode; + res.resume = () => {}; + onResponse(res); + process.nextTick(() => { + res.emit("data", Buffer.from("error body")); + res.emit("end"); + }); + }); + return req; +}; + +const runAsyncTests = async () => { + await withMockedHttpsGet(mockHttpsGetStatus(404), async () => { + await assert.rejects( + calculateChecksum("https://nodejs.org/dist/not-found.tar.gz"), + /Request failed with status code 404/ + ); + }); +}; + +runAsyncTests() + .then(() => { + console.log("update_node_archives validation tests passed"); + }) + .catch((err) => { + console.error(err); + process.exit(1); + });