Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"scripts/install.js",
"scripts/install-wizard.js",
"scripts/run.js",
"checksums.txt",
"CHANGELOG.md"
],
"dependencies": {
Expand Down
126 changes: 101 additions & 25 deletions scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}`;
Expand All @@ -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
Expand All @@ -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);

Expand All @@ -66,6 +78,9 @@ function install() {
download(MIRROR_URL, archivePath);
}

const expectedHash = getExpectedChecksum(archiveName);
verifyChecksum(archivePath, expectedHash);

if (isWindows) {
execFileSync("powershell", [
"-Command",
Expand All @@ -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 (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
Comment thread
MaxHuang22 marked this conversation as resolved.
}

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;
}

if (isNpxPostinstall) {
process.exit(0);
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 changes: 166 additions & 0 deletions scripts/install.test.js
Original file line number Diff line number Diff line change
@@ -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
);
});
});
Loading