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
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:
@@ -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 不匹配/);
|
||||
|
||||
Reference in New Issue
Block a user