mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: add SHA-256 checksum verification to install.js (#592)
* refactor: make install.js side-effect-free on require Change-Id: I5444e3f34642d7c0740b6422a70ca6921a85e363 * feat: add getExpectedChecksum with unit tests Change-Id: I87548be25d30c384e743da17b1d161b9d9f0ea87 * feat: add verifyChecksum with unit tests Change-Id: Ifc2067bf1b824b02257dba7b53716fbe18d0f6b6 * feat: harden download with host allowlist and checksum verification Change-Id: I2580782866049f1f62a2597e86b7bf59d0e50925 * ci: bundle checksums.txt in npm package for install verification Change-Id: I2d7c44d9d5b9075158f63c0f8cf66c1e0abe3d8d * ci: use triggering tag and verify checksums.txt presence in release workflow Address CodeRabbit review: use GITHUB_REF_NAME instead of parsing package.json to avoid version drift, and add explicit file check to fail loudly if checksums.txt is missing or empty. Change-Id: I8a5658412b6afc338ad2a642baba146cceafd0fc * feat: streaming hash, allowlist tests, and malformed-line coverage - verifyChecksum: switch from readFileSync to streaming 64KB chunks to avoid loading entire archive (10-100MB) into memory - Export and test assertAllowedHost: 7 cases covering allowed hosts, rejection, case normalization, port handling, invalid URL - Add ALLOWED_HOSTS comment clarifying it only gates initial URL - Add getExpectedChecksum tests for malformed/tab-separated lines Change-Id: Ida639def89c242b3b261a76effae08fd414a10dc
This commit is contained in:
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -45,6 +45,15 @@ jobs:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"scripts/install.js",
|
||||
"scripts/install-wizard.js",
|
||||
"scripts/run.js",
|
||||
"checksums.txt",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,10 +5,20 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
const os = require("os");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const VERSION = require("../package.json").version.replace(/-.*$/, "");
|
||||
const REPO = "larksuite/cli";
|
||||
const NAME = "lark-cli";
|
||||
// Allowlist gates the *initial* request URL only. curl --location follows
|
||||
// redirects (capped by --max-redirs 3) without re-checking the target host.
|
||||
// This is acceptable because checksum verification is the primary integrity
|
||||
// control; the allowlist is defense-in-depth to reject obviously wrong URLs.
|
||||
const ALLOWED_HOSTS = [
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"registry.npmmirror.com",
|
||||
];
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
darwin: "darwin",
|
||||
@@ -24,13 +34,6 @@ const ARCH_MAP = {
|
||||
const platform = PLATFORM_MAP[process.platform];
|
||||
const arch = ARCH_MAP[process.arch];
|
||||
|
||||
if (!platform || !arch) {
|
||||
console.error(
|
||||
`Unsupported platform: ${process.platform}-${process.arch}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const ext = isWindows ? ".zip" : ".tar.gz";
|
||||
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
|
||||
@@ -40,12 +43,19 @@ const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
function assertAllowedHost(url) {
|
||||
const { hostname } = new URL(url);
|
||||
if (!ALLOWED_HOSTS.includes(hostname)) {
|
||||
throw new Error(`Download host not allowed: ${hostname}`);
|
||||
}
|
||||
}
|
||||
|
||||
function download(url, destPath) {
|
||||
assertAllowedHost(url);
|
||||
const args = [
|
||||
"--fail", "--location", "--silent", "--show-error",
|
||||
"--connect-timeout", "10", "--max-time", "120",
|
||||
"--max-redirs", "3",
|
||||
"--output", destPath,
|
||||
];
|
||||
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
|
||||
@@ -56,6 +66,8 @@ function download(url, destPath) {
|
||||
}
|
||||
|
||||
function install() {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
|
||||
const archivePath = path.join(tmpDir, archiveName);
|
||||
|
||||
@@ -66,6 +78,9 @@ function install() {
|
||||
download(MIRROR_URL, archivePath);
|
||||
}
|
||||
|
||||
const expectedHash = getExpectedChecksum(archiveName);
|
||||
verifyChecksum(archivePath, expectedHash);
|
||||
|
||||
if (isWindows) {
|
||||
execFileSync("powershell", [
|
||||
"-Command",
|
||||
@@ -88,24 +103,85 @@ function install() {
|
||||
}
|
||||
}
|
||||
|
||||
// When triggered as a postinstall hook under npx, skip the binary download.
|
||||
// The "install" wizard doesn't need it, and run.js calls install.js directly
|
||||
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
|
||||
const isNpxPostinstall =
|
||||
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
|
||||
function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const dir = checksumsDir || path.join(__dirname, "..");
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (isNpxPostinstall) {
|
||||
process.exit(0);
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const idx = trimmed.indexOf(" ");
|
||||
if (idx === -1) continue;
|
||||
const hash = trimmed.slice(0, idx);
|
||||
const name = trimmed.slice(idx + 2);
|
||||
if (name === archiveName) return hash;
|
||||
}
|
||||
|
||||
throw new Error(`Checksum entry not found for ${archiveName}`);
|
||||
}
|
||||
|
||||
try {
|
||||
install();
|
||||
} catch (err) {
|
||||
console.error(`Failed to install ${NAME}:`, err.message);
|
||||
console.error(
|
||||
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
|
||||
` export https_proxy=http://your-proxy:port\n` +
|
||||
` npm install -g @larksuite/cli`
|
||||
);
|
||||
process.exit(1);
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (expectedHash === null) return;
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
const hash = crypto.createHash("sha256");
|
||||
const fd = fs.openSync(archivePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(64 * 1024);
|
||||
let bytesRead;
|
||||
while ((bytesRead = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
|
||||
hash.update(buf.subarray(0, bytesRead));
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
const actual = hash.digest("hex");
|
||||
|
||||
if (actual.toLowerCase() !== expectedHash.toLowerCase()) {
|
||||
throw new Error(
|
||||
`[SECURITY] Checksum mismatch for ${path.basename(archivePath)}: expected ${expectedHash} but got ${actual}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (!platform || !arch) {
|
||||
console.error(
|
||||
`Unsupported platform: ${process.platform}-${process.arch}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// When triggered as a postinstall hook under npx, skip the binary download.
|
||||
// The "install" wizard doesn't need it, and run.js calls install.js directly
|
||||
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
|
||||
const isNpxPostinstall =
|
||||
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
|
||||
|
||||
if (isNpxPostinstall) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
install();
|
||||
} catch (err) {
|
||||
console.error(`Failed to install ${NAME}:`, err.message);
|
||||
console.error(
|
||||
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
|
||||
` export https_proxy=http://your-proxy:port\n` +
|
||||
` npm install -g @larksuite/cli`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost };
|
||||
|
||||
166
scripts/install.test.js
Normal file
166
scripts/install.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const { getExpectedChecksum, verifyChecksum, assertAllowedHost } = require("./install.js");
|
||||
|
||||
describe("getExpectedChecksum", () => {
|
||||
function makeTmpChecksums(content) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
fs.writeFileSync(path.join(dir, "checksums.txt"), content, "utf8");
|
||||
return dir;
|
||||
}
|
||||
|
||||
it("returns correct hash from standard-format checksums.txt", () => {
|
||||
const dir = makeTmpChecksums(
|
||||
"abc123def456 lark-cli-1.0.0-darwin-arm64.tar.gz\n"
|
||||
);
|
||||
const hash = getExpectedChecksum(
|
||||
"lark-cli-1.0.0-darwin-arm64.tar.gz",
|
||||
dir
|
||||
);
|
||||
assert.equal(hash, "abc123def456");
|
||||
});
|
||||
|
||||
it("returns correct entry when multiple entries exist", () => {
|
||||
const dir = makeTmpChecksums(
|
||||
"aaaa lark-cli-1.0.0-linux-amd64.tar.gz\n" +
|
||||
"bbbb lark-cli-1.0.0-darwin-arm64.tar.gz\n" +
|
||||
"cccc lark-cli-1.0.0-windows-amd64.zip\n"
|
||||
);
|
||||
const hash = getExpectedChecksum(
|
||||
"lark-cli-1.0.0-darwin-arm64.tar.gz",
|
||||
dir
|
||||
);
|
||||
assert.equal(hash, "bbbb");
|
||||
});
|
||||
|
||||
it("throws Error when archiveName is not found", () => {
|
||||
const dir = makeTmpChecksums(
|
||||
"aaaa lark-cli-1.0.0-linux-amd64.tar.gz\n"
|
||||
);
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("nonexistent.tar.gz", dir),
|
||||
{ message: /Checksum entry not found for nonexistent\.tar\.gz/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
const dir = makeTmpChecksums(
|
||||
"garbage line without separator\n" +
|
||||
"\n" +
|
||||
"abc123 lark-cli-1.0.0-darwin-arm64.tar.gz\n" +
|
||||
"also garbage\n"
|
||||
);
|
||||
const hash = getExpectedChecksum(
|
||||
"lark-cli-1.0.0-darwin-arm64.tar.gz",
|
||||
dir
|
||||
);
|
||||
assert.equal(hash, "abc123");
|
||||
});
|
||||
|
||||
it("skips tab-separated lines (only double-space is valid)", () => {
|
||||
const dir = makeTmpChecksums(
|
||||
"wrong\tlark-cli-1.0.0-darwin-arm64.tar.gz\n" +
|
||||
"correct lark-cli-1.0.0-darwin-arm64.tar.gz\n"
|
||||
);
|
||||
const hash = getExpectedChecksum(
|
||||
"lark-cli-1.0.0-darwin-arm64.tar.gz",
|
||||
dir
|
||||
);
|
||||
assert.equal(hash, "correct");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyChecksum", () => {
|
||||
function makeTmpFile(content) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
const filePath = path.join(dir, "archive.tar.gz");
|
||||
fs.writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
it("returns normally when hash matches", () => {
|
||||
const content = "binary content here";
|
||||
const filePath = makeTmpFile(content);
|
||||
const hash = sha256(content);
|
||||
// Should not throw
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
const content = "case test";
|
||||
const filePath = makeTmpFile(content);
|
||||
const hash = sha256(content).toUpperCase();
|
||||
// Should not throw
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
it("throws [SECURITY]-prefixed Error on mismatch", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "0000000000000000000000000000000000000000000000000000000000000000"),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /Checksum mismatch/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertAllowedHost", () => {
|
||||
it("accepts github.com", () => {
|
||||
assertAllowedHost("https://github.com/larksuite/cli/releases/download/v1.0.0/archive.tar.gz");
|
||||
});
|
||||
|
||||
it("accepts objects.githubusercontent.com", () => {
|
||||
assertAllowedHost("https://objects.githubusercontent.com/some/path");
|
||||
});
|
||||
|
||||
it("accepts registry.npmmirror.com", () => {
|
||||
assertAllowedHost("https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/archive.tar.gz");
|
||||
});
|
||||
|
||||
it("rejects unknown host", () => {
|
||||
assert.throws(
|
||||
() => assertAllowedHost("https://evil.example.com/payload"),
|
||||
{ message: /Download host not allowed: evil\.example\.com/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes hostname to lowercase", () => {
|
||||
// URL constructor lowercases hostnames per spec
|
||||
assertAllowedHost("https://GitHub.COM/larksuite/cli/releases/download/v1.0.0/a.tar.gz");
|
||||
});
|
||||
|
||||
it("ignores port when matching hostname", () => {
|
||||
// URL.hostname does not include port
|
||||
assertAllowedHost("https://github.com:443/larksuite/cli/releases/download/v1.0.0/a.tar.gz");
|
||||
});
|
||||
|
||||
it("throws on invalid URL", () => {
|
||||
assert.throws(
|
||||
() => assertAllowedHost("not-a-url"),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user