fix: harden proxied client installation
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-08-18 16:01:06 +00:00
parent 40c5021c85
commit a08a2c1a96
15 changed files with 513 additions and 87 deletions
+172 -30
View File
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
export interface ReleaseArtifact {
url: string;
mirrors?: string[];
sha256: string;
}
@@ -20,6 +21,7 @@ export interface ClientReleaseManifest {
createdAt: string;
gatewayUrl: string;
signing: { algorithm: "Ed25519"; keyId: string };
distribution?: { manifestMirrors?: string[] };
requirements: {
node: string;
managedNode?: { version: string; platforms: string[] };
@@ -39,6 +41,7 @@ export interface ClientInstallState {
version: string;
installedAt: string;
manifestUrl: string;
manifestMirrors?: string[];
gatewayUrl: string;
clientSha256: string;
skillSha256: string;
@@ -211,6 +214,7 @@ export async function runClientInstaller(
interface ParsedArgs {
command: string;
manifestUrl?: string;
manifestMirrorUrls: string[];
publicKey?: string;
json: boolean;
yes: boolean;
@@ -225,17 +229,22 @@ function parseClientArgs(argv: string[]): ParsedArgs {
if (values[0] === "--print-path") values.splice(0, 1, "print-path");
const command = values.shift() ?? "";
const strings = new Map<string, string>();
const stringLists = new Map<string, string[]>();
const flags = new Set<string>();
let pathKind: string | undefined;
const stringOptions = new Set(["--manifest-url", "--public-key", "--app-root", "--format"]);
const stringOptions = new Set(["--manifest-url", "--manifest-mirror-url", "--public-key", "--app-root", "--format"]);
const flagOptions = new Set(["--json", "--yes", "--allow-downgrade"]);
for (let index = 0; index < values.length; index += 1) {
const value = values[index]!;
if (stringOptions.has(value)) {
const next = values[index + 1];
if (!next || next.startsWith("--")) throw new Error(`${value} 缺少参数`);
if (strings.has(value)) throw new Error(`${value} 不能重复`);
strings.set(value, next);
if (value === "--manifest-mirror-url") {
stringLists.set(value, [...(stringLists.get(value) ?? []), next]);
} else {
if (strings.has(value)) throw new Error(`${value} 不能重复`);
strings.set(value, next);
}
index += 1;
} else if (flagOptions.has(value)) {
if (flags.has(value)) throw new Error(`${value} 不能重复`);
@@ -245,20 +254,22 @@ function parseClientArgs(argv: string[]): ParsedArgs {
else throw new Error(`多余参数:${value}`);
}
const allowed = new Set(command === "install" || command === "update"
? ["--manifest-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
? ["--manifest-url", "--manifest-mirror-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
: command === "login" ? ["--app-root", "--json"]
: command === "uninstall" ? ["--app-root", "--json", "--yes"]
: command === "print-path" ? ["--app-root", "--json"]
: command === "print-config" ? ["--app-root", "--json", "--format"]
: command === "doctor" ? ["--app-root", "--json"] : []);
for (const name of [...strings.keys(), ...flags]) {
for (const name of [...strings.keys(), ...stringLists.keys(), ...flags]) {
if (!allowed.has(name)) throw new Error(`${command || "该命令"} 不支持参数 ${name}`);
}
const format = strings.get("--format");
if (format && format !== "mcp-json") throw new Error("--format 只支持 mcp-json");
if ((stringLists.get("--manifest-mirror-url")?.length ?? 0) > 4) throw new Error("--manifest-mirror-url 最多允许 4 个");
return {
command,
manifestUrl: strings.get("--manifest-url"),
manifestMirrorUrls: stringLists.get("--manifest-mirror-url") ?? [],
publicKey: strings.get("--public-key"),
json: flags.has("--json"),
yes: flags.has("--yes"),
@@ -286,16 +297,18 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context
const npmCheck = checkNpm(context);
if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`);
const previous = readState(paths.stateFile);
const manifestUrl = normalizedHttpsUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL");
if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
const manifestUrls = configuredManifestUrls(args, previous);
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
const publicKey = readFileSync(publicKeyPath);
reportInstallProgress(context, "2/6", "下载并验证签名发布清单");
const [manifestBytes, signatureBytes] = await Promise.all([
download(context.fetchFn, manifestUrl),
download(context.fetchFn, new URL("manifest.sig", manifestUrl)),
const { manifest } = await downloadSignedManifest(context.fetchFn, manifestUrls, publicKey, (failed, next) => {
reportInstallProgress(context, "2/6", `下载源不可用(${failed.origin}),切换至 ${next.origin}`);
});
const persistedManifestUrls = uniqueUrls([
...manifestUrls,
...(manifest.distribution?.manifestMirrors ?? []).map((value) => normalizedManifestUrl(value, "manifest mirror URL")),
]);
const manifest = verifySignedManifest(manifestBytes, signatureBytes, publicKey, manifestUrl.toString());
if (persistedManifestUrls.length > 5) throw new Error("签名清单与本地配置合计最多允许 4 个备用源");
if (previous && compareVersions(manifest.version, previous.version) < 0 && !args.allowDowngrade) {
throw new Error(`拒绝从 ${previous.version} 降级到 ${manifest.version};如确需降级请显式使用 --allow-downgrade`);
}
@@ -304,9 +317,15 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context
reportInstallProgress(context, "3/6", `下载并校验 ${manifest.version} 客户端发布物`);
const clientArchive = join(temporary, "client.tgz");
const [, skillBody, publicArtifact] = await Promise.all([
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive),
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md")),
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem")),
downloadArtifact(context.fetchFn, manifest.artifacts.client, clientArchive, (failed, next) => {
reportInstallProgress(context, "3/6", `客户端包下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
downloadArtifact(context.fetchFn, manifest.artifacts.skill, join(temporary, "SKILL.md"), (failed, next) => {
reportInstallProgress(context, "3/6", `Skill 下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
downloadArtifact(context.fetchFn, manifest.artifacts.publicKey, join(temporary, "release-public.pem"), (failed, next) => {
reportInstallProgress(context, "3/6", `发布公钥下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
]);
if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) {
throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装");
@@ -347,7 +366,8 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context
schemaVersion: 1,
version: manifest.version,
installedAt: new Date().toISOString(),
manifestUrl: manifestUrl.toString(),
manifestUrl: persistedManifestUrls[0]!.toString(),
...(persistedManifestUrls.length > 1 ? { manifestMirrors: persistedManifestUrls.slice(1).map(String) } : {}),
gatewayUrl: manifest.gatewayUrl,
clientSha256: manifest.artifacts.client.sha256,
skillSha256: manifest.artifacts.skill.sha256,
@@ -455,13 +475,14 @@ export function runNpm(
options: { cache?: string; timeoutMs: number },
) {
const invocation = npmInvocation(context, args);
const npmEnv = npmChildEnvironment(context.env);
return spawnSync(invocation.command, invocation.args, {
encoding: "utf8",
timeout: options.timeoutMs,
killSignal: "SIGTERM",
maxBuffer: 2 * 1024 * 1024,
env: {
...context.env,
...npmEnv,
...(options.cache ? { npm_config_cache: options.cache } : {}),
npm_config_update_notifier: "false",
},
@@ -469,6 +490,26 @@ export function runNpm(
});
}
export function npmChildEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const child = { ...env };
for (const key of Object.keys(child)) {
const normalized = key.toLowerCase();
if (normalized === "node_use_env_proxy") {
delete child[key];
continue;
}
if (normalized !== "node_options") continue;
const value = child[key] ?? "";
const cleaned = value
.replace(/(^|[\t\r\n ])--use-env-proxy(?=$|[\t\r\n ])/g, "$1")
.replace(/[\t ]{2,}/g, " ")
.trim();
if (cleaned) child[key] = cleaned;
else delete child[key];
}
return child;
}
export function npmFailureMessage(
result: { error?: Error; signal: NodeJS.Signals | null; status: number | null; stderr: string; stdout: string },
timeoutMs: number,
@@ -676,7 +717,7 @@ function attemptLogin(
verifyTool: { name: "approval_list_todos" as const, arguments: { source: "all" as const, query: "" as const, limit: 1 as const } },
completionCriteria: "登录确认并完成钉钉 OA 与薪福通双来源只读验证",
};
const result = spawnSync(process.execPath, [clientEntry, "--url", gatewayUrl, "--login"], {
const result = spawnSync(process.execPath, [...nodeProxyArgs(context.env), clientEntry, "--url", gatewayUrl, "--login"], {
encoding: "utf8",
timeout: 30_000,
maxBuffer: 256 * 1024,
@@ -734,6 +775,20 @@ function attemptLogin(
};
}
export function nodeProxyArgs(
env: NodeJS.ProcessEnv,
allowedFlags: ReadonlySet<string> = process.allowedNodeEnvironmentFlags,
): string[] {
const proxyConfigured = Object.entries(env).some(([key, value]) =>
["https_proxy", "http_proxy", "all_proxy"].includes(key.toLowerCase()) && Boolean(value?.trim()));
if (!proxyConfigured || !allowedFlags.has("--use-env-proxy")) return [];
const nodeOptions = Object.entries(env).find(([key]) => key.toLowerCase() === "node_options")?.[1] ?? "";
const enabledByNodeOptions = /(^|[\t\r\n ])--use-env-proxy(?=$|[\t\r\n ])/.test(nodeOptions);
const enabledByDedicatedVariable = Object.entries(env).some(([key, value]) =>
key.toLowerCase() === "node_use_env_proxy" && /^(?:1|true)$/i.test(value?.trim() ?? ""));
return enabledByNodeOptions || enabledByDedicatedVariable ? [] : ["--use-env-proxy"];
}
function safeOnboarding(value: unknown): PostInstallResult["onboarding"] | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
@@ -775,7 +830,7 @@ function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillB
function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform, nodeExecutable: string): void {
mkdirPrivate(paths.binDir);
const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`;
const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst proxyConfigured = Object.entries(env).some(([key, value]) => ["https_proxy", "http_proxy", "all_proxy"].includes(key.toLowerCase()) && Boolean(value?.trim()));\nconst nodeOptions = Object.entries(env).find(([key]) => key.toLowerCase() === "node_options")?.[1] ?? "";\nconst proxyAlreadyEnabled = /(^|[\\t\\r\\n ])--use-env-proxy(?=$|[\\t\\r\\n ])/.test(nodeOptions) || Object.entries(env).some(([key, value]) => key.toLowerCase() === "node_use_env_proxy" && /^(?:1|true)$/i.test(value?.trim() ?? ""));\nconst proxyArgs = proxyConfigured && process.allowedNodeEnvironmentFlags.has("--use-env-proxy") && !proxyAlreadyEnabled ? ["--use-env-proxy"] : [];\nconst child = spawn(process.execPath, [...proxyArgs, target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`;
writeAtomic(paths.launcherFile, Buffer.from(launcher), 0o700);
const bodies = clientLauncherBodies(platform, nodeExecutable);
writeAtomic(paths.cliCommand, Buffer.from(bodies.cli), 0o700);
@@ -805,13 +860,19 @@ function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string,
if (manifest.schemaVersion !== 3 || !VERSION_PATTERN.test(manifest.version)) throw new Error("发布清单版本或版本号无效");
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing.keyId !== keyId) throw new Error("发布清单未绑定当前发布公钥");
const base = normalizedHttpsUrl(manifestUrl, "manifest URL");
if (!base.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
const gateway = normalizedHttpsUrl(manifest.gatewayUrl, "gateway URL");
if (gateway.pathname !== "/mcp" || gateway.search || gateway.hash) throw new Error("gateway URL 必须是 HTTPS /mcp 地址");
for (const [name, artifact] of Object.entries(manifest.artifacts ?? {})) {
if (!artifact || !/^[a-f0-9]{64}$/.test(artifact.sha256)) throw new Error(`发布物 ${name} 缺少有效 SHA-256`);
const url = normalizedHttpsUrl(artifact.url, `${name} URL`);
if (url.origin !== base.origin) throw new Error(`发布物 ${name} 必须与清单同源`);
const urls = artifactUrls(artifact, `${name} URL`);
if (urls.length !== new Set(urls.map(String)).size) throw new Error(`发布物 ${name} 包含重复下载地址`);
}
const manifestMirrors = manifest.distribution?.manifestMirrors ?? [];
if (!Array.isArray(manifestMirrors)) throw new Error("manifest mirrors 必须是数组");
if (manifestMirrors.length > 4) throw new Error("manifest mirrors 最多允许 4 个");
for (const value of manifestMirrors) normalizedManifestUrl(value, "manifest mirror URL");
if (manifestMirrors.length !== new Set(manifestMirrors).size) throw new Error("manifest mirrors 包含重复地址");
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("发布公钥 artifact 的 keyId 不匹配");
const createdAt = Date.parse(manifest.createdAt);
if (!Number.isFinite(createdAt)) throw new Error("发布清单 createdAt 无效");
@@ -819,6 +880,13 @@ function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string,
if (createdAt < Date.now() - MANIFEST_MAX_AGE_MS) throw new Error("发布清单已超过 180 天有效期,请获取新的安装链接");
}
class DownloadFailure extends Error {
constructor(message: string, readonly fallbackAllowed: boolean, options?: ErrorOptions) {
super(message, options);
this.name = "DownloadFailure";
}
}
async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("下载超时")), DOWNLOAD_TIMEOUT_MS);
@@ -828,7 +896,7 @@ async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
headers: { accept: "application/octet-stream, application/json;q=0.9, */*;q=0.8" },
signal: controller.signal,
});
if (!response.ok) throw new Error(`下载失败 ${response.status}:${url}`);
if (!response.ok) throw new DownloadFailure(`下载失败 ${response.status}:${url}`, response.status === 404);
const length = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(length) && length > MAX_DOWNLOAD_BYTES) throw new Error(`下载文件超过 25MB 限制:${url}`);
if (!response.body) return Buffer.alloc(0);
@@ -847,20 +915,94 @@ async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
}
return Buffer.concat(chunks, total);
} catch (error) {
if (controller.signal.aborted) throw new Error(`下载超时(30 秒):${url}`, { cause: error });
if (controller.signal.aborted) throw new DownloadFailure(`下载超时(30 秒):${url}`, true, { cause: error });
if (error instanceof DownloadFailure) throw error;
if (error instanceof TypeError || isNetworkError(error)) {
throw new DownloadFailure(`网络连接失败:${url}`, true, { cause: error });
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function downloadArtifact(fetchFn: typeof fetch, manifestUrl: URL, artifact: ReleaseArtifact, target: string): Promise<Buffer> {
const url = normalizedHttpsUrl(artifact.url, "artifact URL");
if (url.origin !== manifestUrl.origin) throw new Error("发布物必须与清单同源");
const body = await download(fetchFn, url);
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
writeAtomic(target, body, 0o600);
return body;
async function downloadSignedManifest(
fetchFn: typeof fetch,
urls: URL[],
publicKey: Buffer,
onFallback?: (failed: URL, next: URL) => void,
): Promise<{ manifest: ClientReleaseManifest; source: URL }> {
let lastError: unknown;
for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]!;
try {
const manifestBytes = await download(fetchFn, url);
const signatureBytes = await download(fetchFn, new URL("manifest.sig", url));
return { manifest: verifySignedManifest(manifestBytes, signatureBytes, publicKey, url.toString()), source: url };
} catch (error) {
lastError = error;
const next = urls[index + 1];
if (!next || !(error instanceof DownloadFailure) || !error.fallbackAllowed) throw error;
onFallback?.(url, next);
}
}
throw lastError instanceof Error ? lastError : new Error("所有签名清单下载源均不可用");
}
async function downloadArtifact(
fetchFn: typeof fetch,
artifact: ReleaseArtifact,
target: string,
onFallback?: (failed: URL, next: URL) => void,
): Promise<Buffer> {
const urls = artifactUrls(artifact, "artifact URL");
let lastError: unknown;
for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]!;
try {
const body = await download(fetchFn, url);
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
writeAtomic(target, body, 0o600);
return body;
} catch (error) {
lastError = error;
const next = urls[index + 1];
if (!next || !(error instanceof DownloadFailure) || !error.fallbackAllowed) throw error;
onFallback?.(url, next);
}
}
throw lastError instanceof Error ? lastError : new Error("所有发布物下载源均不可用");
}
function configuredManifestUrls(args: ParsedArgs, previous: ClientInstallState | undefined): URL[] {
const primary = normalizedManifestUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL");
const inherited = args.manifestUrl ? [] : (previous?.manifestMirrors ?? []);
const urls = uniqueUrls([primary, ...inherited.map((value) => normalizedManifestUrl(value, "manifest mirror URL")),
...args.manifestMirrorUrls.map((value) => normalizedManifestUrl(value, "manifest mirror URL"))]);
if (urls.length > 5) throw new Error("manifest 下载地址最多允许 1 个主源和 4 个备用源");
return urls;
}
function normalizedManifestUrl(value: string, label: string): URL {
const url = normalizedHttpsUrl(value, label);
if (!url.pathname.endsWith("/manifest.json") || url.search || url.hash) throw new Error(`${label} 必须是 HTTPS manifest.json 地址`);
return url;
}
function artifactUrls(artifact: ReleaseArtifact, label: string): URL[] {
if (artifact.mirrors !== undefined && !Array.isArray(artifact.mirrors)) throw new Error(`${label} mirrors 必须是数组`);
if ((artifact.mirrors?.length ?? 0) > 4) throw new Error(`${label} mirrors 最多允许 4 个`);
return [artifact.url, ...(artifact.mirrors ?? [])].map((value) => normalizedHttpsUrl(value, label));
}
function uniqueUrls(values: URL[]): URL[] {
return [...new Map(values.map((value) => [value.toString(), value])).values()];
}
function isNetworkError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code
?? ((error as { cause?: NodeJS.ErrnoException } | undefined)?.cause?.code);
return typeof code === "string" && ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "ETIMEDOUT"].includes(code);
}
function resolvePublicKeyPath(paths: ClientPaths, explicit: string | undefined): string {
@@ -1018,7 +1160,7 @@ function writeResult(value: Record<string, unknown>, args: ParsedArgs, context:
}
function usage(): string {
return "用法:dingtalk-approval install|update|login|uninstall|print-path|print-config|doctor [--manifest-url URL] [--format mcp-json] [--json]";
return "用法:dingtalk-approval install|update|login|uninstall|print-path|print-config|doctor [--manifest-url URL] [--manifest-mirror-url URL] [--format mcp-json] [--json]";
}
if (isClientDirectEntrypoint(import.meta.url, process.argv[1])) {