Root cause
Injection & unsafe execution introduced
Fix command injection vulnerability in SSH operations
How AI contributed: Direct introductionPublished Apr 14, 2026 in
aiondadotcom/mcp-ssh
SSH/SCP option injection leading to local RCE (GHSA-p4h8-56qp-hpgv): MCP tool param hostAlias is passed directly into spawn('ssh', ['-o','StrictHostKeyChecking=accept-new', hostAlias, command]) / execFile('scp', [...]) without a '--' argument terminator, so a crafted hostAlias like '-oProxyCommand=...' is parsed by ssh/scp as an option and executes arbitrary commands locally, bypassing the @password annotation protection. On Windows, spawnOptions.shell=true additionally exposed command/path args to cmd.exe metacharacter injection. Fix 1b6ac6d adds '--' to all ssh/scp calls, a strict hostAlias whitelist (rejects leading '-'), a known-host check (~/.ssh/config incl. Includes or known_hosts), and shell:false with absolute ssh.exe/scp.exe resolution.
Root cause
Fix command injection vulnerability in SSH operations
Fix
Fix SSH/SCP option injection and harden host validation (1.3.5)
Case facts
@@ -18,11 +18,12 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); // Required libraries-const { spawn, exec } = require('child_process');+const { spawn, exec, execFile } = require('child_process'); const { promisify } = require('util'); const sshConfig = require('ssh-config'); const execAsync = promisify(exec);+const execFileAsync = promisify(execFile); // Silent mode for MCP clients - disable debug output when used as MCP server const SILENT_MODE = process.env.MCP_SILENT === 'true' || process.argv.includes('--silent');@@ -167,11 +168,10 @@ class SSHClient { async runRemoteCommand(hostAlias, command) { try {- // Use local ssh command - much simpler and more reliable- const sshCommand = `ssh "${hostAlias}" "${command.replace(/"/g, '\\"')}"`;- debugLog(`Executing: ${sshCommand}\n`);+ // Use execFile for security - prevents command injection+ debugLog(`Executing: ssh ${hostAlias} ${command}\n`); - const { stdout, stderr } = await execAsync(sshCommand, {+ const { stdout, stderr } = await execFileAsync('ssh', [hostAlias, command], { timeout: 30000, // 30 second timeout maxBuffer: 1024 * 1024 * 10 // 10MB buffer });@@ -217,10 +217,11 @@ class SSHClient { async uploadFile(hostAlias, localPath, remotePath) { try {- const scpCommand = `scp "${localPath}" "${hostAlias}:${remotePath}"`;- debugLog(`Executing: ${scpCommand}\n`);+ debugLog(`Executing: scp ${localPath} ${hostAlias}:${remotePath}\n`); - await execAsync(scpCommand, { timeout: 60000 }); // 60 second timeout for file transfer+ await execFileAsync('scp', [localPath, `${hostAlias}:${remotePath}`], { + timeout: 60000 // 60 second timeout for file transfer+ }); return true; } catch (error) { debugLog(`Error uploading file to ${hostAlias}: ${error.message}\n`);@@ -230,10 +231,11 @@ class SSHClient { async downloadFile(hostAlias, remotePath, localPath) { try {- const scpCommand = `scp "${hostAlias}:${remotePath}" "${localPath}"`;- debugLog(`Executing: ${scpCommand}\n`);+ debugLog(`Executing: scp ${hostAlias}:${remotePath} ${localPath}\n`); - await execAsync(scpCommand, { timeout: 60000 }); // 60 second timeout for file transfer+ await execFileAsync('scp', [`${hostAlias}:${remotePath}`, localPath], { + timeout: 60000 // 60 second timeout for file transfer+ }); return true; } catch (error) { debugLog(`Error downloading file from ${hostAlias}: ${error.message}\n`);@@ -399,6 +399,21 @@ Host main expect(result).toEqual([]); }); + it('should treat Windows drive-letter paths as absolute', () => {+ const result = parser.expandIncludePath('C:\\nonexistent-absolute-path-xyz', '/base/config');+ expect(result).toEqual([]);+ });++ it('should treat UNC paths as absolute', () => {+ const result = parser.expandIncludePath('\\\\server\\share\\nonexistent-path-xyz', '/base/config');+ expect(result).toEqual([]);+ });++ it('should expand tilde paths with backslashes', () => {+ const result = parser.expandIncludePath('~\\nonexistent-path-xyz', '/base');+ expect(result).toEqual([]);+ });+ it('should return empty for non-existent glob patterns', () => { const result = parser.expandIncludePath('/nonexistent-path-xyz/*.conf', '/base'); expect(result).toEqual([]);@@ -504,7 +519,7 @@ describe('SSHClient', () => { expect(env.MCP_SSH_PASS).toBe('killer99'); expect(env.SSH_ASKPASS).toContain('mcp-ssh-askpass'); expect(env.SSH_ASKPASS_REQUIRE).toBe('force');- expect(env.DISPLAY).toBe(':0');+ expect(env.DISPLAY).toBe(process.env.DISPLAY); }); it('should throw if config has insecure permissions', async () => {@@ -530,7 +545,7 @@ describe('SSHClient', () => { expect(client._spawn).toHaveBeenCalledWith( 'ssh',- ['-o', 'StrictHostKeyChecking=accept-new', 'test', 'echo hello'],+ ['-o', 'StrictHostKeyChecking=accept-new', '--', 'test', 'echo hello'], expect.any(Object) ); expect(result).toEqual({ stdout: 'hello\n', stderr: '', code: 0 });@@ -628,6 +643,63 @@ describe('SSHClient', () => { expect(result.stdout).toContain('[Output truncated'); }); + it('should reject hostAlias starting with - to block ProxyCommand injection', async () => {+ client._spawn = createMockSpawn({ stdout: 'pwned', code: 0 });++ await expect(+ client.runRemoteCommand('-oProxyCommand=touch /tmp/pwned', 'echo')+ ).rejects.toThrow(/Invalid hostAlias/);+ expect(client._spawn).not.toHaveBeenCalled();+ });++ it('should reject hostAlias containing shell metacharacters (Windows cmd.exe vector)', async () => {+ client._spawn = createMockSpawn({ stdout: '', code: 0 });++ for (const evil of ['foo & calc.exe', 'foo|calc', 'foo;ls', 'foo`id`', 'foo$(id)', 'foo"bar', "foo'bar"]) {+ await expect(client.runRemoteCommand(evil, 'ls')).rejects.toThrow(/Invalid hostAlias/);+ }+ expect(client._spawn).not.toHaveBeenCalled();+ });++ it('should reject unknown hostAlias that is not in ssh config or known_hosts', async () => {+ readFile+ .mockResolvedValueOnce(`Host test\n HostName 1.2.3.4\n`)+ .mockResolvedValueOnce('');+ client._spawn = createMockSpawn({ stdout: '', code: 0 });++ await expect(client.runRemoteCommand('unknown.example.com', 'ls')).rejects.toThrow(/Unknown hostAlias/);+ expect(client._spawn).not.toHaveBeenCalled();+ });++ it('should allow user@alias when alias exists in ssh config', async () => {+ client._spawn = createMockSpawn({ stdout: 'ok\n', code: 0 });++ const result = await client.runRemoteCommand('root@test', 'whoami');++ expect(client._spawn).toHaveBeenCalledWith(+ 'ssh',+ ['-o', 'StrictHostKeyChecking=accept-new', '--', 'root@test', 'whoami'],+ expect.any(Object)+ );+ expect(result.code).toBe(0);+ });++ it('should allow hosts discovered through Include directives', async () => {+ readFile.mockImplementation(async (filePath) => {+ if (String(filePath).endsWith('/config')) return SAMPLE_SSH_CONFIG_WITH_INCLUDE;+ if (String(filePath).endsWith('.conf')) return `Host included\n HostName 10.10.10.10\n`;+ if (String(filePath).endsWith('known_hosts')) return '';@@ -20,13 +20,39 @@ const require = createRequire(import.meta.url); // Required libraries const { spawn, exec, execFile } = require('child_process'); const { promisify } = require('util');+const { statSync } = require('fs'); const sshConfig = require('ssh-config'); const execAsync = promisify(exec); const execFileAsync = promisify(execFile); const isWindows = process.platform === 'win32'; +// Resolve an executable's absolute path on Windows by walking PATH and PATHEXT.+// This lets us call spawn() with shell:false on Windows — without it we would+// need shell:true to find ssh.exe/scp.exe via PATH, which would route every+// argument through cmd.exe and make characters like &, |, ^, >, " usable for+// local command injection. Returns the bare name on non-Windows (POSIX spawn+// already searches PATH safely).+function resolveExecutable(name) {+ if (!isWindows) return name;+ const pathDirs = (process.env.PATH || process.env.Path || '').split(';');+ const exts = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';');+ for (const dir of pathDirs) {+ if (!dir) continue;+ for (const ext of exts) {+ const candidate = join(dir, name + ext);+ try {+ if (statSync(candidate).isFile()) return candidate;+ } catch {}+ }+ }+ return name + '.exe';+}++const SSH_BIN = resolveExecutable('ssh');+const SCP_BIN = resolveExecutable('scp');+ // Silent mode for MCP clients - disable debug output when used as MCP server const SILENT_MODE = process.env.MCP_SILENT === 'true' || process.argv.includes('--silent'); @@ -90,17 +116,17 @@ class SSHConfigParser { } expandIncludePath(includePath, baseConfigPath) {- const { dirname, resolve } = require('path');+ const { dirname, resolve, isAbsolute, win32 } = require('path'); const { glob } = require('glob'); const { existsSync } = require('fs'); // Handle tilde expansion- if (includePath.startsWith('~/')) {- includePath = includePath.replace('~', homedir());+ if (/^~(?=[\\/])/.test(includePath)) {+ includePath = includePath.replace(/^~/, homedir()); } // Handle relative paths- if (!includePath.startsWith('/')) {+ if (!isAbsolute(includePath) && !win32.isAbsolute(includePath)) { const baseDir = dirname(baseConfigPath); includePath = resolve(baseDir, includePath); }@@ -276,6 +302,38 @@ class SSHClient { return await this.configParser.getAllKnownHosts(); } + _assertSafeHostAlias(hostAlias) {+ if (typeof hostAlias !== 'string' || hostAlias.length === 0) {+ throw new Error('hostAlias must be a non-empty string');+ }+ // Strict whitelist. Two threats this defends against:+ // 1. ssh/scp option injection via leading '-' (e.g. -oProxyCommand=…),+ // which would execute arbitrary commands LOCALLY on this machine.+ // 2. cmd.exe metacharacter injection on Windows, where spawnOptions.shell+ // is true and characters like &, |, ^, >, " would otherwise be+ // interpreted by the shell before ssh.exe ever sees them.+ // Allowed: alphanumerics, '.', '_', '-', ':', '@'. Must not start with '-'.+ if (!/^[A-Za-z0-9_.@:][A-Za-z0-9._@:-]*$/.test(hostAlias)) {+ throw new Error(+ `Invalid hostAlias: must match [A-Za-z0-9._@:-] and not start with '-'`+ );+ }+ }++ async _assertKnownHostAlias(hostAlias) {+ const cleanAlias = hostAlias.includes('@') ? hostAlias.split('@').pop() : hostAlias;+ const knownHosts = await this.configParser.getAllKnownHosts();+ const isKnown = knownHosts.some((host) =>+ host.alias === hostAlias ||+ host.hostname === hostAlias ||@@ -531,6 +531,23 @@ Host staging IdentitiesOnly yes ``` +## Threat Model and Trust Boundaries++MCP SSH Agent gives an LLM the ability to drive `ssh` and `scp` on your behalf. Before using it, it is important to understand what an attacker who controls the LLM's instructions (for example via **prompt injection** through web pages, e‑mails, repository files, or another MCP server's output) can do:++- **`runRemoteCommand` is full remote code execution on every host you have configured.** If your `~/.ssh/config` contains an entry for `production`, the LLM can run arbitrary commands as that user on that host. This is the tool's purpose, not a bug — but it means you should only configure hosts that you are willing to let the LLM touch, and prefer least-privilege accounts.+- **`uploadFile` and `downloadFile` give the LLM access to the local filesystem** with the privileges of the user running the MCP server. The LLM can read any file the process can read (including `~/.ssh/id_*`, browser data, source trees, `.env` files) and write to any path it can write to (including `~/.ssh/authorized_keys`). The path arguments are not sandboxed because the tool's contract is "transfer arbitrary files".+- **The MCP server runs locally over STDIO, but the LLM is not trusted.** STDIO only describes the transport — the *content* of tool arguments is chosen by the model, which can be steered by any untrusted text it ingests during the conversation.+- **`# @password:` annotations are kept out of the LLM's context**, but they live in `~/.ssh/config` on disk. Anything that gives an attacker arbitrary local file read (see above) also exposes those passwords. The annotation only protects against the LLM seeing the password through the MCP protocol, not against local file disclosure.++### Recommendations++- Run MCP SSH Agent under a **dedicated, unprivileged OS user** whose home directory only contains the SSH config and keys you actually want the LLM to be able to use.+- Or run it inside a **container / sandbox** with a minimal `~/.ssh` and no access to other secrets on your machine.+- Keep `~/.ssh/config` to the smallest set of hosts you trust the LLM with. Use restricted shell users on the remote side where possible.+- Treat any session where the LLM ingests untrusted content (web pages, e‑mails, third‑party repos, other MCP servers' output) as potentially hostile to MCP SSH operations.+- Report suspected vulnerabilities via GitHub's private vulnerability reporting on this repository.+ ## Security Best Practices ### SSH Key SecurityAI-assisted change f2efcf10b98979d13e591e308be05a42772606e7eba97582c993339f2a4687af · Fix 5f52c31dc321618da013b19747bf57a95d2cd94745b6c61522d90da8287eff42