Skip to content

Commit 97f4019

Browse files
committed
fix(infra): resolve ssh client from trusted system dirs (#83289)
resolveSshConfig and startSshPortForward spawned a hard-coded /usr/bin/ssh, so SSH config discovery and gateway tunneling failed wherever the system ssh client lives elsewhere: Windows (built-in OpenSSH under System32\OpenSSH) and NixOS (/run/current-system/sw/bin). Route both helpers through the existing resolveSystemBin("ssh", { trust: "strict" }) resolver, and add the Windows built-in OpenSSH directory to the resolver's trusted Windows locations (it had System32 but not the System32\OpenSSH subdirectory where ssh.exe actually ships). Fail closed with a clear diagnostic when no system ssh client is present. Closes #83289.
1 parent 3644619 commit 97f4019

6 files changed

Lines changed: 86 additions & 5 deletions

File tree

src/infra/resolve-system-bin.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,29 @@ describe("trusted directory list", () => {
202202
}
203203
});
204204

205+
it("includes the Windows built-in OpenSSH directory and resolves ssh.exe there", () => {
206+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
207+
resetWindowsInstallRootsForTests({
208+
queryRegistryValue: (key, valueName) =>
209+
key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" &&
210+
valueName === "SystemRoot"
211+
? "D:\\Windows"
212+
: null,
213+
});
214+
try {
215+
const sshExe = path.win32.join("D:\\Windows", "System32", "OpenSSH", "ssh.exe");
216+
resetResolveSystemBin((p: string) => p === sshExe);
217+
expect(getTrustedDirsForTest()).toContain(
218+
path.win32.join("D:\\Windows", "System32", "OpenSSH"),
219+
);
220+
expect(resolveSystemBin("ssh")).toBe(sshExe);
221+
} finally {
222+
platformSpy.mockRestore();
223+
resetResolveSystemBin();
224+
resetWindowsInstallRootsForTests();
225+
}
226+
});
227+
205228
it("resolves machine-wide Chocolatey shims only with standard trust on Windows", () => {
206229
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
207230
resetWindowsInstallRootsForTests({

src/infra/resolve-system-bin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ function buildWindowsTrustedDirs(): readonly string[] {
7474
const dirs: string[] = [];
7575
const { systemRoot } = getWindowsInstallRoots();
7676
dirs.push(path.win32.join(systemRoot, "System32"));
77+
// Windows ships its built-in OpenSSH client in this System32 subdirectory;
78+
// ssh.exe is not directly under System32, so it needs its own trusted entry.
79+
dirs.push(path.win32.join(systemRoot, "System32", "OpenSSH"));
7780
dirs.push(path.win32.join(systemRoot, "SysWOW64"));
7881
dirs.push(path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0"));
7982

src/infra/ssh-config.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
33
import { EventEmitter } from "node:events";
44
import { beforeAll, describe, expect, it, vi } from "vitest";
5+
import { resolveSystemBin } from "./resolve-system-bin.js";
56

67
type MockSpawnChild = EventEmitter & {
78
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
@@ -45,7 +46,12 @@ vi.mock("node:child_process", async () => {
4546
);
4647
});
4748

49+
vi.mock("./resolve-system-bin.js", () => ({
50+
resolveSystemBin: vi.fn(() => "/usr/bin/ssh"),
51+
}));
52+
4853
const spawnMock = vi.mocked(spawn);
54+
const resolveSystemBinMock = vi.mocked(resolveSystemBin);
4955

5056
function requireSpawnArgs(index: number): string[] {
5157
const args = spawnMock.mock.calls[index]?.[1] as string[] | undefined;
@@ -98,6 +104,8 @@ describe("ssh-config", () => {
98104
expect(config?.port).toBe(2222);
99105
expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]);
100106
expect(requireSpawnArgs(0).slice(-2)).toEqual(["--", "me@alias"]);
107+
expect(resolveSystemBinMock).toHaveBeenCalledWith("ssh", { trust: "strict" });
108+
expect(spawnMock.mock.calls[0]?.[0]).toBe("/usr/bin/ssh");
101109
});
102110

103111
it("adds non-default port and trimmed identity arguments", async () => {
@@ -139,6 +147,16 @@ describe("ssh-config", () => {
139147
await expect(resolveSshConfig({ user: "me", host: "bad-host", port: 22 })).resolves.toBeNull();
140148
});
141149

150+
it("returns null without spawning when no trusted ssh client is found", async () => {
151+
resolveSystemBinMock.mockReturnValueOnce(null);
152+
spawnMock.mockClear();
153+
154+
const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 });
155+
156+
expect(config).toBeNull();
157+
expect(spawnMock).not.toHaveBeenCalled();
158+
});
159+
142160
it("rejects oversized ssh -G output while preserving the parser contract", () => {
143161
expect(appendSshConfigOutput("user bob", "\nhostname example.com", 128)).toEqual({
144162
ok: true,

src/infra/ssh-config.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Reads effective SSH target config from the local ssh client.
22
import { spawn } from "node:child_process";
33
import { parseStrictPositiveInteger } from "./parse-finite-number.js";
4+
import { resolveSystemBin } from "./resolve-system-bin.js";
45
import type { SshParsedTarget } from "./ssh-tunnel.js";
56

67
export const SSH_CONFIG_OUTPUT_MAX_CHARS = 64 * 1024;
@@ -76,7 +77,12 @@ export async function resolveSshConfig(
7677
target: SshParsedTarget,
7778
opts: { identity?: string; timeoutMs?: number } = {},
7879
): Promise<SshResolvedConfig | null> {
79-
const sshPath = "/usr/bin/ssh";
80+
// Resolve ssh from trusted system directories only (never a PATH-controlled
81+
// binary). Best-effort config read: skip enrichment when no system ssh exists.
82+
const sshPath = resolveSystemBin("ssh", { trust: "strict" });
83+
if (sshPath === null) {
84+
return null;
85+
}
8086
const args = ["-G"];
8187
if (target.port > 0 && target.port !== 22) {
8288
args.push("-p", String(target.port));

src/infra/ssh-tunnel.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
// Covers SSH target parsing.
2-
import { describe, expect, it } from "vitest";
3-
import { parseSshTarget } from "./ssh-tunnel.js";
1+
// Covers SSH target parsing and the trusted ssh client resolution guard.
2+
import { describe, expect, it, vi } from "vitest";
3+
import { resolveSystemBin } from "./resolve-system-bin.js";
4+
import { parseSshTarget, startSshPortForward } from "./ssh-tunnel.js";
5+
6+
vi.mock("./resolve-system-bin.js", () => ({
7+
resolveSystemBin: vi.fn(() => "/usr/bin/ssh"),
8+
}));
9+
10+
const resolveSystemBinMock = vi.mocked(resolveSystemBin);
411

512
describe("parseSshTarget", () => {
613
it("parses user@host:port targets", () => {
@@ -30,3 +37,18 @@ describe("parseSshTarget", () => {
3037
expect(parseSshTarget("-oProxyCommand=echo")).toBeNull();
3138
});
3239
});
40+
41+
describe("startSshPortForward", () => {
42+
it("fails closed with a clear diagnostic when no trusted ssh client is found", async () => {
43+
resolveSystemBinMock.mockReturnValueOnce(null);
44+
45+
await expect(
46+
startSshPortForward({
47+
target: "[email protected]",
48+
localPortPreferred: 12345,
49+
remotePort: 8080,
50+
timeoutMs: 100,
51+
}),
52+
).rejects.toThrow(/no trusted SSH client found/);
53+
});
54+
});

src/infra/ssh-tunnel.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-norm
55
import { formatErrorMessage, isErrno } from "./errors.js";
66
import { parseStrictPositiveInteger } from "./parse-finite-number.js";
77
import { ensurePortAvailable } from "./ports.js";
8+
import { resolveSystemBin } from "./resolve-system-bin.js";
89

910
export type SshParsedTarget = {
1011
user?: string;
@@ -117,6 +118,14 @@ export async function startSshPortForward(opts: {
117118
throw new Error(`invalid SSH target: ${opts.target}`);
118119
}
119120

121+
// Resolve ssh from trusted system directories only (never a PATH-controlled
122+
// binary). Fail closed with a clear diagnostic when no system ssh client is
123+
// present, e.g. a Windows host without the built-in OpenSSH feature installed.
124+
const sshPath = resolveSystemBin("ssh", { trust: "strict" });
125+
if (sshPath === null) {
126+
throw new Error("no trusted SSH client found in system directories");
127+
}
128+
120129
let localPort = opts.localPortPreferred;
121130
try {
122131
await ensurePortAvailable(localPort);
@@ -157,7 +166,7 @@ export async function startSshPortForward(opts: {
157166
args.push("--", userHost);
158167

159168
const stderr: string[] = [];
160-
const child = spawn("/usr/bin/ssh", args, {
169+
const child = spawn(sshPath, args, {
161170
stdio: ["ignore", "ignore", "pipe"],
162171
});
163172
child.stderr?.setEncoding("utf8");

0 commit comments

Comments
 (0)