From 29bb565ea6405e2dd5a0ea65fe64da117e76055e Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Fri, 29 May 2026 15:38:43 -0700 Subject: [PATCH] reject hostname option injection via bracketed mount source A source like [-oProxyCommand=CMD]:/path passes the bracket-parsing check in find_base_path() and ends up as -oProxyCommand=CMD in the ssh argv. When sftp_server is a path, ssh gets a destination argument and executes the injected ProxyCommand before connecting. Reject hostnames starting with - after bracket stripping, and add -- before the hostname in the ssh command line so positional args can't be misread as options. --- sshfs.c | 8 ++++- test/meson.build | 2 +- test/test_hostname_validation.py | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 test/test_hostname_validation.py diff --git a/sshfs.c b/sshfs.c index 1bb83c76..67d6a247 100644 --- a/sshfs.c +++ b/sshfs.c @@ -4019,6 +4019,11 @@ static char *find_base_path(void) *d++ = '\0'; s++; + if (sshfs.host[0] == '-') { + fprintf(stderr, "invalid hostname '%s'\n", sshfs.host); + exit(1); + } + return s; } @@ -4410,7 +4415,6 @@ int main(int argc, char *argv[]) tmp = g_strdup_printf("-%i", sshfs.ssh_ver); ssh_add_arg(tmp); g_free(tmp); - ssh_add_arg(sshfs.host); if (sshfs.sftp_server) sftp_server = sshfs.sftp_server; else if (sshfs.ssh_ver == 1) @@ -4421,6 +4425,8 @@ int main(int argc, char *argv[]) if (sshfs.ssh_ver != 1 && strchr(sftp_server, '/') == NULL) ssh_add_arg("-s"); + ssh_add_arg("--"); + ssh_add_arg(sshfs.host); ssh_add_arg(sftp_server); free(sshfs.sftp_server); diff --git a/test/meson.build b/test/meson.build index c0edde2d..4b26321f 100644 --- a/test/meson.build +++ b/test/meson.build @@ -1,5 +1,5 @@ test_scripts = [ 'conftest.py', 'pytest.ini', 'test_sshfs.py', - 'util.py' ] + 'test_hostname_validation.py', 'util.py' ] custom_target('test_scripts', input: test_scripts, output: test_scripts, build_by_default: true, command: ['cp', '-fPp', diff --git a/test/test_hostname_validation.py b/test/test_hostname_validation.py new file mode 100644 index 00000000..07b0c4f2 --- /dev/null +++ b/test/test_hostname_validation.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Tests for hostname validation — no FUSE mount required.""" + +if __name__ == "__main__": + import pytest + import sys + + sys.exit(pytest.main([__file__] + sys.argv[1:])) + +import subprocess +from util import base_cmdline, basename +from os.path import join as pjoin + + +def test_reject_option_injection_in_hostname(tmpdir): + """Bracketed source that resolves to a dash-prefixed host must be rejected.""" + + mnt_dir = str(tmpdir.mkdir("mnt")) + malicious = "[-oProxyCommand=echo pwned]:/path" + + cmdline = base_cmdline + [ + pjoin(basename, "sshfs"), + "-f", + malicious, + mnt_dir, + ] + res = subprocess.run( + cmdline, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + text=True, + ) + assert res.returncode != 0 + assert "invalid hostname" in res.stderr + + +def test_reject_dash_host_after_doubledash(tmpdir): + """Non-bracketed dash-prefixed source after -- must also be rejected.""" + + mnt_dir = str(tmpdir.mkdir("mnt")) + + cmdline = base_cmdline + [ + pjoin(basename, "sshfs"), + "-f", + "--", + "-oProxyCommand=echo pwned:/path", + mnt_dir, + ] + res = subprocess.run( + cmdline, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + text=True, + ) + assert res.returncode != 0 + assert "invalid hostname" in res.stderr