Files
openclaw/src/agents/sandbox/ssh.ts
Vincent Koc 3d5af14984 fix(agents): reject escaping symlinks in ssh sandbox uploads (#58220)
* fix(agents): reject escaping ssh sandbox upload symlinks

* fix(agents): allow safe ssh upload symlink aliases

* test(ssh): keep upload stdin open in fake ssh

* Update CHANGELOG.md
2026-03-31 19:56:45 +09:00

400 lines
12 KiB
TypeScript

import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveBoundaryPath } from "../../infra/boundary-path.js";
import { parseSshTarget } from "../../infra/ssh-tunnel.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { resolveUserPath } from "../../utils.js";
import type { SandboxBackendCommandResult } from "./backend.js";
import { sanitizeEnvVars } from "./sanitize-env-vars.js";
export type SshSandboxSettings = {
command: string;
target: string;
strictHostKeyChecking: boolean;
updateHostKeys: boolean;
identityFile?: string;
certificateFile?: string;
knownHostsFile?: string;
identityData?: string;
certificateData?: string;
knownHostsData?: string;
};
export type SshSandboxSession = {
command: string;
configPath: string;
host: string;
};
export type RunSshSandboxCommandParams = {
session: SshSandboxSession;
remoteCommand: string;
stdin?: Buffer | string;
allowFailure?: boolean;
signal?: AbortSignal;
tty?: boolean;
};
function normalizeInlineSshMaterial(contents: string, filename: string): string {
const withoutBom = contents.replace(/^\uFEFF/, "");
const normalizedNewlines = withoutBom.replace(/\r\n?/g, "\n");
const normalizedEscapedNewlines = normalizedNewlines
.replace(/\\r\\n/g, "\\n")
.replace(/\\r/g, "\\n");
const expanded =
filename === "identity" || filename === "certificate.pub"
? normalizedEscapedNewlines.replace(/\\n/g, "\n")
: normalizedEscapedNewlines;
return expanded.endsWith("\n") ? expanded : `${expanded}\n`;
}
function buildSshFailureMessage(stderr: string, exitCode?: number): string {
const trimmed = stderr.trim();
if (
trimmed.includes("error in libcrypto") &&
(trimmed.includes('Load key "') || trimmed.includes("Permission denied (publickey)"))
) {
return `${trimmed}\nSSH sandbox failed to load the configured identity. The private key contents may be malformed (for example CRLF or escaped newlines). Prefer identityFile when possible.`;
}
return (
trimmed ||
(exitCode !== undefined
? `ssh exited with code ${exitCode}`
: "ssh exited with a non-zero status")
);
}
export function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export function buildRemoteCommand(argv: string[]): string {
return argv.map((entry) => shellEscape(entry)).join(" ");
}
export function buildExecRemoteCommand(params: {
command: string;
workdir?: string;
env: Record<string, string>;
}): string {
const body = params.workdir
? `cd ${shellEscape(params.workdir)} && ${params.command}`
: params.command;
const argv =
Object.keys(params.env).length > 0
? [
"env",
...Object.entries(params.env).map(([key, value]) => `${key}=${value}`),
"/bin/sh",
"-c",
body,
]
: ["/bin/sh", "-c", body];
return buildRemoteCommand(argv);
}
export function buildSshSandboxArgv(params: {
session: SshSandboxSession;
remoteCommand: string;
tty?: boolean;
}): string[] {
return [
params.session.command,
"-F",
params.session.configPath,
...(params.tty
? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"]
: ["-T", "-o", "RequestTTY=no"]),
params.session.host,
params.remoteCommand,
];
}
export async function createSshSandboxSessionFromConfigText(params: {
configText: string;
host?: string;
command?: string;
}): Promise<SshSandboxSession> {
const host = params.host?.trim() || parseSshConfigHost(params.configText);
if (!host) {
throw new Error("Failed to parse SSH config output.");
}
const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-"));
const configPath = path.join(configDir, "config");
await fs.writeFile(configPath, params.configText, { encoding: "utf8", mode: 0o600 });
await fs.chmod(configPath, 0o600);
return {
command: params.command?.trim() || "ssh",
configPath,
host,
};
}
export async function createSshSandboxSessionFromSettings(
settings: SshSandboxSettings,
): Promise<SshSandboxSession> {
const parsed = parseSshTarget(settings.target);
if (!parsed) {
throw new Error(`Invalid sandbox SSH target: ${settings.target}`);
}
const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-"));
try {
const materializedIdentity = settings.identityData
? await writeSecretMaterial(configDir, "identity", settings.identityData)
: undefined;
const materializedCertificate = settings.certificateData
? await writeSecretMaterial(configDir, "certificate.pub", settings.certificateData)
: undefined;
const materializedKnownHosts = settings.knownHostsData
? await writeSecretMaterial(configDir, "known_hosts", settings.knownHostsData)
: undefined;
const identityFile = materializedIdentity ?? resolveOptionalLocalPath(settings.identityFile);
const certificateFile =
materializedCertificate ?? resolveOptionalLocalPath(settings.certificateFile);
const knownHostsFile =
materializedKnownHosts ?? resolveOptionalLocalPath(settings.knownHostsFile);
const hostAlias = "openclaw-sandbox";
const configPath = path.join(configDir, "config");
const lines = [
`Host ${hostAlias}`,
` HostName ${parsed.host}`,
` Port ${parsed.port}`,
" BatchMode yes",
" ConnectTimeout 5",
" ServerAliveInterval 15",
" ServerAliveCountMax 3",
` StrictHostKeyChecking ${settings.strictHostKeyChecking ? "yes" : "no"}`,
` UpdateHostKeys ${settings.updateHostKeys ? "yes" : "no"}`,
];
if (parsed.user) {
lines.push(` User ${parsed.user}`);
}
if (knownHostsFile) {
lines.push(` UserKnownHostsFile ${knownHostsFile}`);
} else if (!settings.strictHostKeyChecking) {
lines.push(" UserKnownHostsFile /dev/null");
}
if (identityFile) {
lines.push(` IdentityFile ${identityFile}`);
}
if (certificateFile) {
lines.push(` CertificateFile ${certificateFile}`);
}
if (identityFile || certificateFile) {
lines.push(" IdentitiesOnly yes");
}
await fs.writeFile(configPath, `${lines.join("\n")}\n`, {
encoding: "utf8",
mode: 0o600,
});
await fs.chmod(configPath, 0o600);
return {
command: settings.command.trim() || "ssh",
configPath,
host: hostAlias,
};
} catch (error) {
await fs.rm(configDir, { recursive: true, force: true });
throw error;
}
}
export async function disposeSshSandboxSession(session: SshSandboxSession): Promise<void> {
await fs.rm(path.dirname(session.configPath), { recursive: true, force: true });
}
export async function runSshSandboxCommand(
params: RunSshSandboxCommandParams,
): Promise<SandboxBackendCommandResult> {
const argv = buildSshSandboxArgv({
session: params.session,
remoteCommand: params.remoteCommand,
tty: params.tty,
});
const sshEnv = sanitizeEnvVars(process.env).allowed;
return await new Promise<SandboxBackendCommandResult>((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), {
stdio: ["pipe", "pipe", "pipe"],
env: sshEnv,
signal: params.signal,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on("error", reject);
child.on("close", (code) => {
const stdout = Buffer.concat(stdoutChunks);
const stderr = Buffer.concat(stderrChunks);
const exitCode = code ?? 0;
if (exitCode !== 0 && !params.allowFailure) {
reject(
Object.assign(new Error(buildSshFailureMessage(stderr.toString("utf8"), exitCode)), {
code: exitCode,
stdout,
stderr,
}),
);
return;
}
resolve({ stdout, stderr, code: exitCode });
});
if (params.stdin !== undefined) {
child.stdin.end(params.stdin);
return;
}
child.stdin.end();
});
}
export async function uploadDirectoryToSshTarget(params: {
session: SshSandboxSession;
localDir: string;
remoteDir: string;
signal?: AbortSignal;
}): Promise<void> {
await assertSafeUploadSymlinks(params.localDir);
const remoteCommand = buildRemoteCommand([
"/bin/sh",
"-c",
'mkdir -p -- "$1" && tar -xf - -C "$1"',
"openclaw-sandbox-upload",
params.remoteDir,
]);
const sshArgv = buildSshSandboxArgv({
session: params.session,
remoteCommand,
});
const sshEnv = sanitizeEnvVars(process.env).allowed;
await new Promise<void>((resolve, reject) => {
const tar = spawn("tar", ["-C", params.localDir, "-cf", "-", "."], {
stdio: ["ignore", "pipe", "pipe"],
signal: params.signal,
});
const ssh = spawn(sshArgv[0], sshArgv.slice(1), {
stdio: ["pipe", "pipe", "pipe"],
env: sshEnv,
signal: params.signal,
});
const tarStderr: Buffer[] = [];
const sshStdout: Buffer[] = [];
const sshStderr: Buffer[] = [];
let tarClosed = false;
let sshClosed = false;
let tarCode = 0;
let sshCode = 0;
tar.stderr.on("data", (chunk) => tarStderr.push(Buffer.from(chunk)));
ssh.stdout.on("data", (chunk) => sshStdout.push(Buffer.from(chunk)));
ssh.stderr.on("data", (chunk) => sshStderr.push(Buffer.from(chunk)));
const fail = (error: unknown) => {
tar.kill("SIGKILL");
ssh.kill("SIGKILL");
reject(error);
};
tar.on("error", fail);
ssh.on("error", fail);
tar.stdout.pipe(ssh.stdin);
tar.on("close", (code) => {
tarClosed = true;
tarCode = code ?? 0;
maybeResolve();
});
ssh.on("close", (code) => {
sshClosed = true;
sshCode = code ?? 0;
maybeResolve();
});
function maybeResolve() {
if (!tarClosed || !sshClosed) {
return;
}
if (tarCode !== 0) {
reject(
new Error(
Buffer.concat(tarStderr).toString("utf8").trim() || `tar exited with code ${tarCode}`,
),
);
return;
}
if (sshCode !== 0) {
reject(
new Error(
Buffer.concat(sshStderr).toString("utf8").trim() || `ssh exited with code ${sshCode}`,
),
);
return;
}
resolve();
}
});
}
async function assertSafeUploadSymlinks(localDir: string): Promise<void> {
const rootDir = path.resolve(localDir);
await walkDirectory(rootDir);
async function walkDirectory(currentDir: string): Promise<void> {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name);
if (entry.isSymbolicLink()) {
try {
await resolveBoundaryPath({
absolutePath: entryPath,
rootPath: rootDir,
boundaryLabel: "SSH sandbox upload tree",
});
} catch (error) {
const relativePath = path.relative(rootDir, entryPath).split(path.sep).join("/");
throw new Error(
`SSH sandbox upload refuses symlink escaping the workspace: ${relativePath}`,
{ cause: error },
);
}
continue;
}
if (entry.isDirectory()) {
await walkDirectory(entryPath);
}
}
}
}
function parseSshConfigHost(configText: string): string | null {
const hostMatch = configText.match(/^\s*Host\s+(\S+)/m);
return hostMatch?.[1]?.trim() || null;
}
function resolveSshTmpRoot(): string {
return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir());
}
function resolveOptionalLocalPath(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? resolveUserPath(trimmed) : undefined;
}
async function writeSecretMaterial(
dir: string,
filename: string,
contents: string,
): Promise<string> {
const pathname = path.join(dir, filename);
await fs.writeFile(pathname, normalizeInlineSshMaterial(contents, filename), {
encoding: "utf8",
mode: 0o600,
});
await fs.chmod(pathname, 0o600);
return pathname;
}