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
+66 -2
View File
@@ -10,7 +10,7 @@ import { basename, delimiter, dirname, join } from "node:path";
import test from "node:test";
import {
acquireClientInstallLock, cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId,
npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest,
nodeProxyArgs, npmChildEnvironment, npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest,
type ClientReleaseManifest,
} from "../src/client-installer.js";
@@ -86,6 +86,50 @@ test("npm subprocess timeout is bounded and returns an actionable diagnostic", (
}
});
test("npm subprocess removes Node fetch proxy flags without dropping npm proxy settings", () => {
const input = {
NODE_OPTIONS: "--trace-warnings --use-env-proxy --max-old-space-size=512",
NODE_USE_ENV_PROXY: "1",
HTTPS_PROXY: "http://proxy.example.test:8080",
NO_PROXY: "localhost,127.0.0.1",
};
const output = npmChildEnvironment(input);
assert.equal(output.NODE_OPTIONS, "--trace-warnings --max-old-space-size=512");
assert.equal(output.NODE_USE_ENV_PROXY, undefined);
assert.equal(output.HTTPS_PROXY, input.HTTPS_PROXY);
assert.equal(output.NO_PROXY, input.NO_PROXY);
assert.equal(input.NODE_OPTIONS.includes("--use-env-proxy"), true, "the caller environment is not mutated");
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY }, new Set(["--use-env-proxy"])), ["--use-env-proxy"]);
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY, NODE_OPTIONS: "--use-env-proxy" }, new Set(["--use-env-proxy"])), []);
assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY }, new Set()), []);
assert.deepEqual(nodeProxyArgs({}, new Set(["--use-env-proxy"])), []);
});
test("runNpm passes the isolated proxy environment to the real child process", () => {
const root = mkdtempSync(join(tmpdir(), "ding-client-npm-env-"));
try {
const inspectNpm = join(root, "inspect-npm.mjs");
writeFileSync(inspectNpm, "console.log(JSON.stringify({nodeOptions:process.env.NODE_OPTIONS,nodeProxy:process.env.NODE_USE_ENV_PROXY,httpsProxy:process.env.HTTPS_PROXY,noProxy:process.env.NO_PROXY}));\n");
const result = runNpm({
env: {
npm_execpath: inspectNpm,
NODE_OPTIONS: "--trace-warnings --use-env-proxy",
NODE_USE_ENV_PROXY: "true",
HTTPS_PROXY: "http://proxy.example.test:8080",
NO_PROXY: "localhost",
},
platform: process.platform,
}, ["inspect"], { timeoutMs: 2_000 });
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), {
nodeOptions: "--trace-warnings", httpsProxy: "http://proxy.example.test:8080", noProxy: "localhost",
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("signed manifest verification rejects tampering and a foreign key", () => {
const trusted = generateKeyPairSync("ed25519");
const foreign = generateKeyPairSync("ed25519");
@@ -158,17 +202,27 @@ test("generic installer preserves identity across safe updates, rollback, downgr
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n\n# Test\n"),
};
const manifest = manifestFixture(keyId, publicPem, bodies);
const mirrorBase = "https://mirror.example.test/dingmcp/";
manifest.distribution = { manifestMirrors: [`${mirrorBase}manifest.json`] };
for (const artifact of Object.values(manifest.artifacts)) {
artifact.mirrors = [`${mirrorBase}${basename(new URL(artifact.url).pathname)}`];
}
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const signature = signatureEnvelope(keyId, sign(null, manifestBytes, keys.privateKey));
const responses = new Map<string, Buffer>([
["https://approval.example.test/downloads/manifest.json", manifestBytes],
["https://approval.example.test/downloads/manifest.sig", signature],
...Object.entries(manifest.artifacts).map(([name, artifact]) => [artifact.url, bodies[name as keyof typeof bodies]] as [string, Buffer]),
[`${mirrorBase}manifest.json`, manifestBytes],
[`${mirrorBase}manifest.sig`, signature],
...Object.entries(manifest.artifacts).flatMap(([name, artifact]) =>
[artifact.url, ...(artifact.mirrors ?? [])].map((url) => [url, bodies[name as keyof typeof bodies]] as [string, Buffer])),
]);
let downloadCalls = 0;
const unavailableUrls = new Set<string>();
const fetchFn = async (input: string | URL | Request) => {
downloadCalls += 1;
const url = String(input);
if (unavailableUrls.has(url)) return new Response("missing", { status: 404 });
const body = responses.get(url);
return body ? new Response(new Uint8Array(body), { status: 200 }) : new Response("missing", { status: 404 });
};
@@ -189,6 +243,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
};
const installed = await runClientInstaller([
"install", "--manifest-url", "https://approval.example.test/downloads/manifest.json",
"--manifest-mirror-url", `${mirrorBase}manifest.json`,
"--public-key", publicPath, "--app-root", appRoot, "--json",
], commandContext);
assert.equal(installed, 0);
@@ -217,6 +272,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.md")), true);
const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(state.version, "1.1.0-rc.5");
assert.deepEqual(state.manifestMirrors, [`${mirrorBase}manifest.json`]);
assert.equal(state.host, undefined);
assert.equal(state.nodeExecutable, realpathSync(process.execPath));
assert.equal(state.managedNode, false);
@@ -226,6 +282,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
});
assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/);
assert.match(readFileSync(paths.bridgeCommand, "utf8"), new RegExp(realpathSync(process.execPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(readFileSync(paths.launcherFile, "utf8"), /allowedNodeEnvironmentFlags\.has\("--use-env-proxy"\)/);
const stableBridgePath = paths.bridgeCommand;
const initialRuntime = state.runtimeDir;
@@ -276,6 +333,9 @@ test("generic installer preserves identity across safe updates, rollback, downgr
output.length = 0;
const interruptedStaging = join(paths.versionsDir, ".staging-interrupted-test");
mkdirSync(interruptedStaging, { recursive: true });
unavailableUrls.add("https://approval.example.test/downloads/manifest.json");
unavailableUrls.add("https://approval.example.test/downloads/manifest.sig");
for (const artifact of Object.values(manifest.artifacts)) unavailableUrls.add(artifact.url);
assert.equal(await runClientInstaller([
"update", "--app-root", appRoot, "--json",
], { ...commandContext, env: { ...env, FAKE_BRIDGE_FAIL: "1" } }), 0, "login preparation failure does not roll back an installed client");
@@ -287,6 +347,8 @@ test("generic installer preserves identity across safe updates, rollback, downgr
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "same-version update preserves the OAuth session");
assert.deepEqual(readdirSync(paths.versionsDir), [basename(initialRuntime)]);
assert.equal(existsSync(interruptedStaging), false, "a dead installer staging directory is recovered on the next run");
assert.match(progress.join(""), /下载源不可用.*mirror\.example\.test/);
unavailableUrls.clear();
output.length = 0;
assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1);
@@ -325,10 +387,12 @@ test("generic installer preserves identity across safe updates, rollback, downgr
const beforeFailedVersions = readdirSync(paths.versionsDir);
const failedBodies = { ...upgradedBodies, client: Buffer.from("fake-client-tgz-v3") };
const failedUpgrade = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
failedUpgrade.artifacts.client.mirrors = [`${mirrorBase}client-v3`];
const failedUpgradeBytes = Buffer.from(`${JSON.stringify(failedUpgrade, null, 2)}\n`);
responses.set("https://approval.example.test/downloads/manifest.json", failedUpgradeBytes);
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, failedUpgradeBytes, keys.privateKey)));
responses.set(failedUpgrade.artifacts.client.url, Buffer.from("corrupted-client-tgz"));
responses.set(failedUpgrade.artifacts.client.mirrors[0]!, failedBodies.client);
output.length = 0;
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 1);
assert.match(JSON.parse(output.pop()!).error, /SHA-256 不匹配/);
+5
View File
@@ -25,6 +25,8 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(installer, /安装长时间无最终 JSON[\s\S]*残留 staging/s);
assert.match(installer, /系统无需预装 Node\.js/);
assert.match(installer, /阿里云精确版本.*npmmirror 精确版本.*Node 官方精确版本/s);
assert.match(installer, /国内备用发布源[\s\S]*主发布源.*国内 Gitea 备用源/s);
assert.match(installer, /不要设置全局 `NODE_OPTIONS=--use-env-proxy`[\s\S]*不要手工预装 `node_modules`/s);
assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/);
assert.match(installer, /Linux\/macOS 安装引导/);
assert.match(installer, /dingtalk-approval-bridge\.cmd/);
@@ -50,10 +52,12 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(windowsBootstrap, /OSArchitecture[\s\S]*Save-DingDownload[\s\S]*Expand-Archive/);
assert.match(windowsBootstrap, /SHA-256 不匹配,停止安装/);
assert.match(windowsBootstrap, /Write-DingProgress[\s\S]*\[dingtalk-approval\]/);
assert.match(windowsBootstrap, /dingInstallerUrls[\s\S]*dingManifestMirrorUrls[\s\S]*use-env-proxy/s);
assert.match(posixBootstrap, /^#!\/bin\/sh\nset -eu/);
assert.match(posixBootstrap, /uname -s[\s\S]*uname -m[\s\S]*tar -xzf/);
assert.match(posixBootstrap, /SHA-256 不匹配,停止安装/);
assert.match(posixBootstrap, /ding_progress[\s\S]*\[dingtalk-approval\]/);
assert.match(posixBootstrap, /ding_installer_urls[\s\S]*ding_manifest_mirror_urls[\s\S]*use-env-proxy/s);
assert.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0);
assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/);
@@ -69,6 +73,7 @@ test("customer distribution is a generic single-login gateway bridge", () => {
const builder = readFileSync("scripts/build-release.mjs", "utf8");
assert.match(builder, /schemaVersion: 3,\s+version,/s);
assert.match(builder, /download-mirror-base-url[\s\S]*manifestMirrors[\s\S]*mirrors:/s);
assert.match(builder, /signData\(null, manifestBytes/);
assert.match(builder, /dingtalk-approval-client/);
assert.match(builder, /bootstrap-windows\.template\.ps1/);
+52 -8
View File
@@ -34,6 +34,36 @@ test("POSIX bootstrap installs a private Node and falls back after a network fai
}
});
test("POSIX bootstrap scopes the Node environment proxy flag to the networked installer", {
skip: process.platform === "win32",
}, () => {
const fixture = createPosixFixture();
try {
const result = runPosixFixture(fixture, "proxy");
assert.equal(result.status, 0, result.stderr);
const invocations = readFileSync(fixture.nodeArgsLog, "utf8").trim().split(/\r?\n/);
assert.ok(invocations.some((value) => value.startsWith("--use-env-proxy ") && value.includes("installer.mjs install")));
} finally {
rmSync(fixture.root, { recursive: true, force: true });
}
});
test("POSIX bootstrap falls back for the signed installer and public key downloads", {
skip: process.platform === "win32",
}, () => {
const fixture = createPosixFixture();
try {
const result = runPosixFixture(fixture, "release-fallback");
assert.equal(result.status, 0, result.stderr);
const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/);
assert.ok(calls.includes("https://release.example.test/installer.mjs"));
assert.ok(calls.includes("https://release-mirror.example.test/installer.mjs"));
assert.ok(calls.includes("https://release-mirror.example.test/release-signing-public.pem"));
} finally {
rmSync(fixture.root, { recursive: true, force: true });
}
});
test("POSIX bootstrap stops immediately when a downloaded runtime has the wrong hash", {
skip: process.platform === "win32",
}, () => {
@@ -94,6 +124,7 @@ interface PosixFixture {
archive: string;
installer: string;
publicKey: string;
nodeArgsLog: string;
}
function createPosixFixture(): PosixFixture {
@@ -105,6 +136,7 @@ function createPosixFixture(): PosixFixture {
const archive = join(root, RUNTIME_ARCHIVE);
const installer = join(root, "installer.mjs");
const publicKey = join(root, "release-signing-public.pem");
const nodeArgsLog = join(root, "node-args.log");
const script = join(root, "bootstrap.sh");
mkdirSync(join(archiveRoot, "bin"), { recursive: true });
mkdirSync(join(archiveRoot, "lib", "node_modules", "npm", "bin"), { recursive: true });
@@ -114,6 +146,7 @@ function createPosixFixture(): PosixFixture {
const privateNode = join(archiveRoot, "bin", "node");
writeFileSync(privateNode, [
"#!/bin/sh",
"if [ -n \"${DING_TEST_NODE_ARGS_LOG:-}\" ]; then printf '%s\\n' \"$*\" >> \"$DING_TEST_NODE_ARGS_LOG\"; fi",
"if [ \"${1:-}\" = '-e' ]; then exit 0; fi",
"printf '%s\\n' '{\"ok\":true,\"status\":\"installed\",\"managedNode\":true}'",
"",
@@ -125,6 +158,7 @@ function createPosixFixture(): PosixFixture {
writeFileSync(installer, "// fixture installer\n");
writeFileSync(publicKey, "fixture public key\n");
writeFileSync(callLog, "");
writeFileSync(nodeArgsLog, "");
const curl = join(fakeBin, "curl");
writeFileSync(curl, `#!/bin/sh
@@ -145,8 +179,14 @@ case "$ding_url" in
printf '000'; exit 7 ;;
https://mirror.example.test/node/*) /bin/cp "$DING_TEST_ARCHIVE" "$ding_target"; printf '200' ;;
https://official.example.test/node/*) /bin/cp "$DING_TEST_ARCHIVE" "$ding_target"; printf '200' ;;
https://release.example.test/installer.mjs) /bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;;
https://release.example.test/release-signing-public.pem) /bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;;
https://release.example.test/installer.mjs)
if [ "$DING_TEST_MODE" = 'release-fallback' ]; then printf '000'; exit 7; fi
/bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;;
https://release.example.test/release-signing-public.pem)
if [ "$DING_TEST_MODE" = 'release-fallback' ]; then printf '000'; exit 7; fi
/bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;;
https://release-mirror.example.test/installer.mjs) /bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;;
https://release-mirror.example.test/release-signing-public.pem) /bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;;
*) printf '404'; exit 22 ;;
esac
`);
@@ -158,11 +198,12 @@ esac
let body = readFileSync("distribution/bootstrap-posix.template.sh", "utf8");
const replacements: Record<string, string> = {
"{{NODE_SOURCE_BASES_POSIX}}": "https://primary.example.test/node/ https://mirror.example.test/node/ https://official.example.test/node/",
"{{INSTALLER_URL}}": "https://release.example.test/installer.mjs",
"{{INSTALLER_URLS_POSIX}}": "https://release.example.test/installer.mjs https://release-mirror.example.test/installer.mjs",
"{{INSTALLER_SHA256}}": digest(installer),
"{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem",
"{{PUBLIC_KEY_URLS_POSIX}}": "https://release.example.test/release-signing-public.pem https://release-mirror.example.test/release-signing-public.pem",
"{{PUBLIC_KEY_SHA256}}": digest(publicKey),
"{{MANIFEST_URL}}": "https://release.example.test/manifest.json",
"{{MANIFEST_MIRROR_URLS_POSIX}}": "https://release-mirror.example.test/manifest.json",
};
for (const platform of ["LINUX_X64", "LINUX_ARM64", "DARWIN_X64", "DARWIN_ARM64"]) {
replacements[`{{NODE_${platform}_FILE}}`] = RUNTIME_ARCHIVE;
@@ -172,10 +213,10 @@ esac
assert.doesNotMatch(body, /\{\{/);
writeFileSync(script, body);
chmodSync(script, 0o700);
return { root, clientRoot, callLog, script, fakeBin, archive, installer, publicKey };
return { root, clientRoot, callLog, script, fakeBin, archive, installer, publicKey, nodeArgsLog };
}
function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "server-error") {
function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "server-error" | "proxy" | "release-fallback") {
return spawnSync("/bin/sh", [fixture.script], {
encoding: "utf8",
env: {
@@ -187,6 +228,8 @@ function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "
DING_TEST_ARCHIVE: fixture.archive,
DING_TEST_INSTALLER: fixture.installer,
DING_TEST_PUBLIC_KEY: fixture.publicKey,
DING_TEST_NODE_ARGS_LOG: fixture.nodeArgsLog,
...(mode === "proxy" ? { HTTPS_PROXY: "http://proxy.example.test:8080" } : {}),
},
});
}
@@ -195,11 +238,12 @@ function renderWindowsBootstrap(): string {
let body = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8");
const replacements: Record<string, string> = {
"{{NODE_SOURCE_BASES_POWERSHELL}}": '"https://primary.example.test/node/", "https://official.example.test/node/"',
"{{INSTALLER_URL}}": "https://release.example.test/installer.mjs",
"{{INSTALLER_URLS_POWERSHELL}}": '"https://release.example.test/installer.mjs", "https://release-mirror.example.test/installer.mjs"',
"{{INSTALLER_SHA256}}": "a".repeat(64),
"{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem",
"{{PUBLIC_KEY_URLS_POWERSHELL}}": '"https://release.example.test/release-signing-public.pem", "https://release-mirror.example.test/release-signing-public.pem"',
"{{PUBLIC_KEY_SHA256}}": "b".repeat(64),
"{{MANIFEST_URL}}": "https://release.example.test/manifest.json",
"{{MANIFEST_MIRROR_URLS_POWERSHELL}}": '"https://release-mirror.example.test/manifest.json"',
};
for (const platform of ["WIN32_X64", "WIN32_ARM64"]) {
replacements[`{{NODE_${platform}_FILE}}`] = `node-v${NODE_VERSION}-win-x64.zip`;