| Version | Supported |
|---|---|
| 0.6.x (unreleased) | Yes |
| ≤ 0.5.6 | No |
Older versions are not supported. Please upgrade to the latest 0.6.x release before reporting an issue.
0.6.0 is not released yet. The latest tag and __version__ are still 0.5.6,
so there is currently no released version carrying the fix below — build from
main or wait for the 0.6.0 release.
0.6.0 contains security fixes. Versions ≤ 0.5.6 are affected by a local-path
confinement flaw in the SFTP tools that allows an MCP client to write to
arbitrary paths on the machine running ssh-mcp, including files that lead to
code execution there. See the [Unreleased] section of the CHANGELOG, which
ships as 0.6.0. Upgrade rather than patching in place; the fix changes the
upload_file/download_file path contract.
Do not open a public GitHub issue for security vulnerabilities.
Report vulnerabilities through GitHub Security Advisories. Include:
- A clear description of the vulnerability
- Steps to reproduce
- Potential impact assessment
- Any suggested mitigations (optional)
You will receive an acknowledgment within 72 hours and a status update within 7 days. If a fix is warranted, a patched release will be coordinated before public disclosure.
| Stage | Target |
|---|---|
| Acknowledgment | 72 hours |
| Status update | 7 days |
| Patch release | 30 days |
| Public disclosure | After patch |
ssh-mcp does not manage SSH credentials directly. Authentication is delegated to the system SSH agent and to an SSH config file — ~/.ssh/config by default, overridable via the ssh_config_path setting. ssh-mcp never stores, prompts for, or transmits a credential of its own.
Two qualifications worth knowing:
- If a server sets
identity_file, that path is handed to asyncssh asclient_keys, so the private key is read and parsed inside the ssh-mcp process. Only the agent path keeps key material out of the process entirely. - There is no passphrase support. A passphrase-protected
identity_filefails to load rather than prompting; use the SSH agent for passphrase-protected keys.
Ensure your SSH keys follow least-privilege principles.
The execute and execute_on_group tools run arbitrary shell commands on remote servers as the configured SSH user. The AI assistant invoking these tools has the same privileges as that user. Apply standard SSH hardening:
- Use dedicated low-privilege accounts where possible
- Restrict sudo access on target servers
- Enable SSH audit logging on remote hosts
ssh-mcp blocks commands matching a list of commonly destructive patterns (e.g. rm -rf /, mkfs, disk wipes) and returns an error instead of executing them. A caller may bypass the block by passing force=true. This is a safety feature, not a vulnerability.
The bypass is not recorded in the audit log. Audit records carry server, command, exit_code and duration_ms only; force is emitted solely as an OpenTelemetry span attribute (ssh.force), and therefore only when the optional otel extra is installed and an exporter is configured. A block is logged as a warning on the operational logger, not the audit logger. If you need a paper trail for bypasses, export traces or withhold force=true at the MCP client.
The pattern list is a tripwire for accidents, not a security boundary: base64-encoded payloads, shell hex escapes, Unicode homoglyphs and indirection via $(...)/eval are acknowledged bypasses. See the README for the full statement of what it does and does not defend against. The responsibility for authorizing commands lies with the operator.
Host key verification is on by default: ssh-mcp uses asyncssh's default verification unless the known_hosts setting is set to false, in which case verification is disabled for every server. That setting is the real kill switch — ssh-mcp only logs a warning and continues. Leave it at true in production.
Disabling host key checking in the SSH config via StrictHostKeyChecking no weakens man-in-the-middle protection the same way and should also be avoided.
servers.toml is resolved from SSH_MCP_CONFIG, else $XDG_CONFIG_HOME/ssh-mcp/servers.toml (defaulting to ~/.config/ssh-mcp/servers.toml), else a config/servers.toml beside the package for development.
Treat it as a security-relevant file, not just inventory. Besides hostnames and group metadata it carries user, identity_file paths, jump_host, transfer_root, and the known_hosts toggle — so write access to it is enough to disable man-in-the-middle protection or repoint the SFTP transfer root. Restrict its permissions to the owning user:
chmod 600 ~/.config/ssh-mcp/servers.tomlThis is advisory: ssh-mcp does not verify the mode or owner of servers.toml. (transfer_root is different — its 0700 mode and ownership are enforced at startup, see below.) A file-permission check on servers.toml would be a welcome hardening contribution, but its absence is a known gap, not a vulnerability report.
The upload_file and download_file tools transfer files using SFTP over the same authenticated SSH session. Since 0.6.0 the local side is confined rather than validated:
- Local paths are relative to
transfer_root(default under$XDG_DATA_HOME, override withSSH_MCP_TRANSFER_ROOT). Absolute paths and..are rejected. - The root is created
0700on demand and must be a real directory owned by the running user and inaccessible to group/other — otherwise ssh-mcp refuses the transfer. - Each path component is opened beneath a pinned directory descriptor with
O_NOFOLLOW, so a symlink anywhere in the path aborts the walk. A caller-supplied local path never reaches the SFTP library. - Downloads use
O_CREAT|O_EXCLand never overwrite; a failed download unlinks its own partial file, guarded by a(st_dev, st_ino)identity check. - Non-regular local files, and existing non-regular remote destinations, are refused.
- A 100 MiB size limit applies: an oversized upload is refused before any bytes move, while an oversized download is warned about after the fact (the count comes from the descriptor just written).
POSIX only. Transfers require dir_fd support plus O_NOFOLLOW/O_DIRECTORY. On a platform without them (including Windows) file transfer fails closed rather than running without symlink protection.
Remote paths are still validated by name, not confined — see below.
Remote paths passed to the SFTP tools are rejected if they contain .. or match a denylist of sensitive locations (/etc/shadow, ~/.ssh/* key material and config, cloud and Kubernetes credentials, .netrc, .pgpass, /proc/<pid>/{environ,mem,maps,…}, and similar).
Like the dangerous-command list, this is a tripwire, not a security boundary: matching is substring-based after normpath and case-folding, .. is blocked lexically only, and no symlink resolution happens on the remote side. Anything not enumerated is permitted, and the remote SSH user's own permissions remain the real boundary.
Known credential patterns in the command string (-p<pass>, --password=, PGPASSWORD=, Authorization: Bearer, URL basic-auth, *_PASSWORD/_SECRET/_TOKEN/_KEY env assignments) are redacted before reaching audit logs, stderr, or OTel span attributes.
This is a tripwire too, with two documented limits: multi-word quoted values are only partially redacted, and command output is never redacted at all — env | grep PASSWORD returns plaintext to the MCP client by design. Pass credentials via env files, secret stores, or stdin rather than argv.
Several bounds exist for denial-of-service reasons and should not be raised casually:
max_command_bytes(default 64 KiB) caps command length at the MCP tool boundary, because redaction and dangerous-pattern matching are superlinear and reachable without an SSH connection or SSH authentication viadry_run=true.max_output_bytesbounds allocation per stream, so the real process ceiling is 2x the configured value (stdout plus stderr).max_parallel_hostsbounds concurrent SSH connections process-wide, not per call.
The streamable HTTP transport is the highest-risk deployment surface: reaching the port means being able to execute commands on the managed fleet.
- The default bind,
127.0.0.1:8000, is unauthenticated by design — it matches the single-user stdio deployment model. This is intentional and not a vulnerability report. - Bearer auth is configured via
SSH_MCP_HTTP_TOKENorSSH_MCP_HTTP_TOKEN_FILE. Tokens must be at least 16 characters and are compared in constant time. Prefer the file form, keep it0600, and rotate by restarting with a new value — there is no online rotation. - Binding a non-localhost address without a token aborts startup. Setting
SSH_MCP_HTTP_AUTH=noneon such a bind additionally requires the literal acknowledgementSSH_MCP_HTTP_NETWORK_NO_AUTH=I_ACCEPT_RCE_RISK. - DNS-rebinding protection is always enabled, and wildcard entries in
SSH_MCP_HTTP_ALLOWED_HOSTSare rejected rather than silently disabling it.
If you expose ssh-mcp beyond loopback, terminate TLS and authenticate at a reverse proxy; the bearer token is a single shared secret, not a user model.
The following behaviors are intentional design decisions, not security flaws:
- Dangerous-command blocks bypassed via the documented
force=trueparameter, or via obfuscation (base64, hex escapes, homoglyphs, subshell indirection) — the pattern list is a documented tripwire, not a security boundary - The tool executing whatever command the invoking AI assistant sends — access control is the operator's responsibility via SSH permissions
- Lack of a built-in allowlist/blocklist for commands — this is a general-purpose infrastructure tool
- Secrets appearing in command output returned to the MCP client, and multi-word quoted credential values being only partially redacted in logs — redaction covers the command string and is a tripwire, documented above
- Remote sensitive paths not on the denylist being reachable via SFTP — remote-path checking is a name-based tripwire; the remote user's permissions are the boundary
- The default
127.0.0.1HTTP bind being unauthenticated — deliberate, and non-localhost binds are gated as described above - The three accepted residual races, all documented in the source: a same-filesystem
rename()of an intermediate directory between twoos.opencalls during the confined walk, the stat-then-unlink gap when removing a partial download, and the non-atomic no-follow check on the remote side (SFTP protocol v3 offers no atomic no-follow open). None of them let a local write escapetransfer_root.