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 不匹配/);