Control: tags 1138293 + pending

Dear maintainer,

I've prepared an NMU for sshfs-fuse (versioned as 3.7.3-1.2) and
uploaded it to DELAYED/2. Please feel free to tell me if I
should cancel it.

Regards
Salvatore
diffstat for sshfs-fuse-3.7.3 sshfs-fuse-3.7.3

 changelog                                                          |   10 
 patches/add-contain_symlinks-option-to-prevent-symlink-escap.patch |  160 ++++++++++
 patches/reject-hostname-option-injection-via-bracketed-mount.patch |  136 ++++++++
 patches/series                                                     |    2 
 4 files changed, 308 insertions(+)

diff -Nru sshfs-fuse-3.7.3/debian/changelog sshfs-fuse-3.7.3/debian/changelog
--- sshfs-fuse-3.7.3/debian/changelog	2023-02-07 20:33:53.000000000 +0100
+++ sshfs-fuse-3.7.3/debian/changelog	2026-05-30 17:20:39.000000000 +0200
@@ -1,3 +1,13 @@
+sshfs-fuse (3.7.3-1.2) unstable; urgency=high
+
+  * Non-maintainer upload.
+  * add contain_symlinks option to prevent symlink escape attacks
+    (CVE-2026-47187) (Closes: #1138293)
+  * reject hostname option injection via bracketed mount source (CVE-2026-48711)
+    (Closes: #1138293)
+
+ -- Salvatore Bonaccorso <[email protected]>  Sat, 30 May 2026 17:20:39 +0200
+
 sshfs-fuse (3.7.3-1.1) unstable; urgency=high
 
   * Non-maintainer upload.
diff -Nru sshfs-fuse-3.7.3/debian/patches/add-contain_symlinks-option-to-prevent-symlink-escap.patch sshfs-fuse-3.7.3/debian/patches/add-contain_symlinks-option-to-prevent-symlink-escap.patch
--- sshfs-fuse-3.7.3/debian/patches/add-contain_symlinks-option-to-prevent-symlink-escap.patch	1970-01-01 01:00:00.000000000 +0100
+++ sshfs-fuse-3.7.3/debian/patches/add-contain_symlinks-option-to-prevent-symlink-escap.patch	2026-05-30 17:18:42.000000000 +0200
@@ -0,0 +1,160 @@
+From: Abhinav Agarwal <[email protected]>
+Date: Sun, 17 May 2026 01:27:17 -0700
+Subject: add contain_symlinks option to prevent symlink escape attacks
+Origin: https://github.com/libfuse/sshfs/commit/bcd132f17ccf1b8592a229df797c9b08883fec26
+Bug: https://github.com/libfuse/sshfs/pull/361
+Bug-Debian: https://bugs.debian.org/1138293
+Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-47187
+
+A malicious SFTP server can return symlink targets that the local
+kernel VFS resolves outside the mount root, enabling local file reads
+or writes through ordinary operations like cp following a symlink.
+
+Add a contain_symlinks option (default on) that rejects absolute
+symlink targets and any target containing a `..` component, returning
+EPERM. Users who need legacy pass-through for trusted servers can opt
+out with -o no_contain_symlinks.
+
+The check is purely lexical and deliberately strict: in an adversarial
+filesystem the server controls intermediate path components, so any
+non-`..` component could be a symlink anywhere, making lexical depth
+tracking unreliable. Rejecting absolute and any `..` is the simplest
+rule that is provably complete against the threat model.
+
+transform_symlinks composes poorly with containment because transformed
+results often contain `..`; a warning is emitted when both are enabled.
+
+Tests cover default-on containment (readlink + open/stat traversal),
+opt-out behavior, transform_symlinks interaction (both arms), and
+option precedence.
+---
+ sshfs.c            |  50 +++++++++++++
+ sshfs.rst          |  15 ++++
+ test/test_sshfs.py | 170 +++++++++++++++++++++++++++++++++++++++++++++
+ 3 files changed, 235 insertions(+)
+
+--- a/sshfs.c
++++ b/sshfs.c
+@@ -312,6 +312,7 @@ struct sshfs {
+ 	int fstat_workaround;
+ 	int createmode_workaround;
+ 	int transform_symlinks;
++	int contain_symlinks;
+ 	int follow_symlinks;
+ 	int no_check_root;
+ 	int detect_uid;
+@@ -493,6 +494,8 @@ static struct fuse_opt sshfs_opts[] = {
+ 	SSHFS_OPT("sshfs_verbose",     verbose, 1),
+ 	SSHFS_OPT("reconnect",         reconnect, 1),
+ 	SSHFS_OPT("transform_symlinks", transform_symlinks, 1),
++	SSHFS_OPT("contain_symlinks", contain_symlinks, 1),
++	SSHFS_OPT("no_contain_symlinks", contain_symlinks, 0),
+ 	SSHFS_OPT("follow_symlinks",   follow_symlinks, 1),
+ 	SSHFS_OPT("no_check_root",     no_check_root, 1),
+ 	SSHFS_OPT("password_stdin",    password_stdin, 1),
+@@ -2104,6 +2107,36 @@ static void strip_common(const char **sp
+ 	} while ((*s == *t && *s) || (!*s && *t == '/') || (*s == '/' && !*t));
+ }
+ 
++/*
++ * Reject symlink targets that could escape the mount root: absolute
++ * paths and any target containing a ".." component.  Returns 1 if
++ * the target is safe to expose to the kernel, 0 otherwise.
++ */
++static int symlink_target_is_contained(const char *target)
++{
++	const char *p = target;
++
++	if (*p == '/')
++		return 0;
++
++	while (*p) {
++		const char *comp = p;
++
++		while (*p && *p != '/')
++			p++;
++		/*
++		 * Reject any ".." rather than try to normalize: in an
++		 * adversarial filesystem the server controls intermediate
++		 * components, so lexical normalization cannot be trusted.
++		 */
++		if (p - comp == 2 && comp[0] == '.' && comp[1] == '.')
++			return 0;
++		while (*p == '/')
++			p++;
++	}
++	return 1;
++}
++
+ static void transform_symlink(const char *path, char **linkp)
+ {
+ 	const char *l = *linkp;
+@@ -2168,6 +2201,13 @@ static int sshfs_readlink(const char *pa
+ 		   buf_get_string(&name, &link) != -1) {
+ 			if (sshfs.transform_symlinks)
+ 				transform_symlink(path, &link);
++			if (sshfs.contain_symlinks &&
++			    !symlink_target_is_contained(link)) {
++				free(link);
++				buf_free(&name);
++				buf_free(&buf);
++				return -EPERM;
++			}
+ 			strncpy(linkbuf, link, size - 1);
+ 			linkbuf[size - 1] = '\0';
+ 			free(link);
+@@ -3641,6 +3681,9 @@ static void usage(const char *progname)
+ "    -o passive             communicate over stdin and stdout bypassing network\n"
+ "    -o disable_hardlink    link(2) will return with errno set to ENOSYS\n"
+ "    -o transform_symlinks  transform absolute symlinks to relative\n"
++"    -o contain_symlinks    reject absolute symlinks and symlinks containing ..\n"
++"                           (enabled by default; disable with no_contain_symlinks)\n"
++"    -o no_contain_symlinks allow all symlink targets including absolute and ..\n"
+ "    -o follow_symlinks     follow symlinks on the server\n"
+ "    -o no_check_root       don't check for existence of 'dir' on server\n"
+ "    -o password_stdin      read password from stdin (only for pam_mount!)\n"
+@@ -4187,6 +4230,7 @@ int main(int argc, char *argv[])
+ 	sshfs.max_conns = 1;
+ 	sshfs.ptyfd = -1;
+ 	sshfs.dir_cache = 1;
++	sshfs.contain_symlinks = 1;
+ 	sshfs.show_help = 0;
+ 	sshfs.show_version = 0;
+ 	sshfs.singlethread = 0;
+@@ -4237,6 +4281,12 @@ int main(int argc, char *argv[])
+ 		exit(1);
+ 	}
+ 
++	if (sshfs.transform_symlinks && sshfs.contain_symlinks)
++		fprintf(stderr, "warning: transform_symlinks with "
++			"contain_symlinks may reject transformed links "
++			"containing '..' - consider adding "
++			"-o no_contain_symlinks\n");
++
+ 	if (sshfs.idmap == IDMAP_USER)
+ 		sshfs.detect_uid = 1;
+ 	else if (sshfs.idmap == IDMAP_FILE) {
+--- a/sshfs.rst
++++ b/sshfs.rst
+@@ -172,6 +172,21 @@ Options
+    ``/foo/bar/com`` is a symlink to ``/foo/blub``, SSHFS will
+    transform the link target to ``../blub`` on the client side.
+ 
++-o contain_symlinks
++   reject symlink targets that are absolute or contain ``..``
++   components.  When a blocked symlink is encountered, readlink
++   returns EPERM.  This is enabled by default to prevent a
++   malicious server from inducing local file reads or writes
++   through crafted symlink targets.  Note that this is stricter
++   than ``transform_symlinks``: the two options should not normally
++   be combined, since transformed results often contain ``..``
++   and would be rejected by containment.
++
++-o no_contain_symlinks
++   disable symlink containment and allow all symlink targets
++   through unchanged, including absolute paths and paths
++   containing ``..``.  Only use this with fully trusted servers.
++
+ -o follow_symlinks
+    follow symlinks on the server, i.e. present them as regular
+    files on the client. If a symlink is dangling (i.e, the target does
diff -Nru sshfs-fuse-3.7.3/debian/patches/reject-hostname-option-injection-via-bracketed-mount.patch sshfs-fuse-3.7.3/debian/patches/reject-hostname-option-injection-via-bracketed-mount.patch
--- sshfs-fuse-3.7.3/debian/patches/reject-hostname-option-injection-via-bracketed-mount.patch	1970-01-01 01:00:00.000000000 +0100
+++ sshfs-fuse-3.7.3/debian/patches/reject-hostname-option-injection-via-bracketed-mount.patch	2026-05-30 17:19:29.000000000 +0200
@@ -0,0 +1,136 @@
+From: Abhinav Agarwal <[email protected]>
+Date: Fri, 29 May 2026 15:38:43 -0700
+Subject: reject hostname option injection via bracketed mount source
+Origin: https://github.com/libfuse/sshfs/commit/29bb565ea6405e2dd5a0ea65fe64da117e76055e
+Bug: https://github.com/libfuse/sshfs/pull/362
+Bug-Debian: https://bugs.debian.org/1138293
+Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-48711
+
+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 1bb83c765e2f..67d6a247181e 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 c0edde2d0482..4b26321f482d 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 000000000000..07b0c4f2bf04
+--- /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
+-- 
+2.53.0
+
diff -Nru sshfs-fuse-3.7.3/debian/patches/series sshfs-fuse-3.7.3/debian/patches/series
--- sshfs-fuse-3.7.3/debian/patches/series	2019-11-16 03:27:57.000000000 +0100
+++ sshfs-fuse-3.7.3/debian/patches/series	2026-05-30 17:19:45.000000000 +0200
@@ -1 +1,3 @@
 #sshfs.1.patch
+add-contain_symlinks-option-to-prevent-symlink-escap.patch
+reject-hostname-option-injection-via-bracketed-mount.patch

Reply via email to