Skip to content

Commit 6893c3a

Browse files
Merge pull request #361 from abhinavagarwal07/contain-symlinks
add contain_symlinks option to prevent symlink escape attacks
2 parents b6b23b7 + bcd132f commit 6893c3a

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

sshfs.c

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ struct sshfs {
313313
int fstat_workaround;
314314
int createmode_workaround;
315315
int transform_symlinks;
316+
int contain_symlinks;
316317
int follow_symlinks;
317318
int no_check_root;
318319
int detect_uid;
@@ -493,6 +494,8 @@ static struct fuse_opt sshfs_opts[] = {
493494
SSHFS_OPT("sshfs_debug", debug, 1),
494495
SSHFS_OPT("reconnect", reconnect, 1),
495496
SSHFS_OPT("transform_symlinks", transform_symlinks, 1),
497+
SSHFS_OPT("contain_symlinks", contain_symlinks, 1),
498+
SSHFS_OPT("no_contain_symlinks", contain_symlinks, 0),
496499
SSHFS_OPT("follow_symlinks", follow_symlinks, 1),
497500
SSHFS_OPT("no_check_root", no_check_root, 1),
498501
SSHFS_OPT("password_stdin", password_stdin, 1),
@@ -2175,6 +2178,36 @@ static void strip_common(const char **sp, const char **tp)
21752178
} while ((*s == *t && *s) || (!*s && *t == '/') || (*s == '/' && !*t));
21762179
}
21772180

2181+
/*
2182+
* Reject symlink targets that could escape the mount root: absolute
2183+
* paths and any target containing a ".." component. Returns 1 if
2184+
* the target is safe to expose to the kernel, 0 otherwise.
2185+
*/
2186+
static int symlink_target_is_contained(const char *target)
2187+
{
2188+
const char *p = target;
2189+
2190+
if (*p == '/')
2191+
return 0;
2192+
2193+
while (*p) {
2194+
const char *comp = p;
2195+
2196+
while (*p && *p != '/')
2197+
p++;
2198+
/*
2199+
* Reject any ".." rather than try to normalize: in an
2200+
* adversarial filesystem the server controls intermediate
2201+
* components, so lexical normalization cannot be trusted.
2202+
*/
2203+
if (p - comp == 2 && comp[0] == '.' && comp[1] == '.')
2204+
return 0;
2205+
while (*p == '/')
2206+
p++;
2207+
}
2208+
return 1;
2209+
}
2210+
21782211
static void transform_symlink(const char *path, char **linkp)
21792212
{
21802213
const char *l = *linkp;
@@ -2239,6 +2272,13 @@ static int sshfs_readlink(const char *path, char *linkbuf, size_t size)
22392272
buf_get_string(&name, &link) != -1) {
22402273
if (sshfs.transform_symlinks)
22412274
transform_symlink(path, &link);
2275+
if (sshfs.contain_symlinks &&
2276+
!symlink_target_is_contained(link)) {
2277+
free(link);
2278+
buf_free(&name);
2279+
buf_free(&buf);
2280+
return -EPERM;
2281+
}
22422282
strncpy(linkbuf, link, size - 1);
22432283
linkbuf[size - 1] = '\0';
22442284
free(link);
@@ -3720,6 +3760,9 @@ static void usage(const char *progname)
37203760
" -o passive communicate over stdin and stdout bypassing network\n"
37213761
" -o disable_hardlink link(2) will return with errno set to ENOSYS\n"
37223762
" -o transform_symlinks transform absolute symlinks to relative\n"
3763+
" -o contain_symlinks reject absolute symlinks and symlinks containing ..\n"
3764+
" (enabled by default; disable with no_contain_symlinks)\n"
3765+
" -o no_contain_symlinks allow all symlink targets including absolute and ..\n"
37233766
" -o follow_symlinks follow symlinks on the server\n"
37243767
" -o no_check_root don't check for existence of 'dir' on server\n"
37253768
" -o password_stdin read password from stdin (only for pam_mount!)\n"
@@ -4277,6 +4320,7 @@ int main(int argc, char *argv[])
42774320
sshfs.max_conns = 1;
42784321
sshfs.ptyfd = -1;
42794322
sshfs.dir_cache = 1;
4323+
sshfs.contain_symlinks = 1;
42804324
sshfs.show_help = 0;
42814325
sshfs.show_version = 0;
42824326
sshfs.singlethread = 0;
@@ -4327,6 +4371,12 @@ int main(int argc, char *argv[])
43274371
exit(1);
43284372
}
43294373

4374+
if (sshfs.transform_symlinks && sshfs.contain_symlinks)
4375+
fprintf(stderr, "warning: transform_symlinks with "
4376+
"contain_symlinks may reject transformed links "
4377+
"containing '..' - consider adding "
4378+
"-o no_contain_symlinks\n");
4379+
43304380
if (sshfs.idmap == IDMAP_USER)
43314381
sshfs.detect_uid = 1;
43324382
else if (sshfs.idmap == IDMAP_FILE) {

sshfs.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,21 @@ Options
175175
``/foo/bar/com`` is a symlink to ``/foo/blub``, SSHFS will
176176
transform the link target to ``../blub`` on the client side.
177177

178+
-o contain_symlinks
179+
reject symlink targets that are absolute or contain ``..``
180+
components. When a blocked symlink is encountered, readlink
181+
returns EPERM. This is enabled by default to prevent a
182+
malicious server from inducing local file reads or writes
183+
through crafted symlink targets. Note that this is stricter
184+
than ``transform_symlinks``: the two options should not normally
185+
be combined, since transformed results often contain ``..``
186+
and would be rejected by containment.
187+
188+
-o no_contain_symlinks
189+
disable symlink containment and allow all symlink targets
190+
through unchanged, including absolute paths and paths
191+
containing ``..``. Only use this with fully trusted servers.
192+
178193
-o follow_symlinks
179194
follow symlinks on the server, i.e. present them as regular
180195
files on the client. If a symlink is dangling (i.e, the target does

test/test_sshfs.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import filecmp
1616
import errno
1717
from tempfile import NamedTemporaryFile
18+
from contextlib import contextmanager
1819
from util import (
1920
wait_for_mount,
2021
umount,
@@ -123,6 +124,9 @@ def test_sshfs(
123124
# FUSE Cache
124125
cmdline += ["-o", "entry_timeout=0", "-o", "attr_timeout=0"]
125126

127+
# Disable containment so tst_symlink can test absolute targets
128+
cmdline += ["-o", "no_contain_symlinks"]
129+
126130
if multiconn:
127131
cmdline += ["-o", "max_conns=3"]
128132

@@ -305,6 +309,12 @@ def tst_symlink(mnt_dir):
305309
assert fstat.st_nlink == 1
306310
assert linkname in os.listdir(mnt_dir)
307311

312+
# Relative symlink without .. should also work
313+
linkname2 = name_generator()
314+
fullname2 = mnt_dir + "/" + linkname2
315+
os.symlink("subdir/file", fullname2)
316+
assert os.readlink(fullname2) == "subdir/file"
317+
308318
os.unlink(fullname)
309319
assert linkname not in os.listdir(mnt_dir)
310320

@@ -910,3 +920,163 @@ def test_bad_sftp_reply_len(tmpdir):
910920
)
911921
assert res.returncode != 0
912922
assert "bad reply len: 0" in res.stderr
923+
924+
925+
@contextmanager
926+
def _sshfs_mount(src_dir, mnt_dir, extra_opts=None):
927+
"""Mount src_dir via sshfs, yield, then unmount."""
928+
cmdline = base_cmdline + [
929+
pjoin(basename, "sshfs"), "-f",
930+
f"localhost:{src_dir}", mnt_dir,
931+
"-o", "entry_timeout=0", "-o", "attr_timeout=0",
932+
]
933+
if extra_opts:
934+
for opt in extra_opts:
935+
cmdline += ["-o", opt]
936+
new_env = dict(os.environ)
937+
new_env["G_DEBUG"] = "fatal-warnings"
938+
mount_process = subprocess.Popen(cmdline, env=new_env)
939+
try:
940+
wait_for_mount(mount_process, mnt_dir)
941+
yield mnt_dir
942+
except Exception:
943+
cleanup(mount_process, mnt_dir)
944+
raise
945+
else:
946+
umount(mount_process, mnt_dir)
947+
948+
949+
def test_contain_symlinks(tmpdir, capfd) -> None:
950+
"""Default containment: safe symlinks resolve, dangerous ones get EPERM."""
951+
952+
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
953+
_check_ssh_localhost()
954+
955+
mnt_dir = str(tmpdir.mkdir("mnt"))
956+
src_dir = str(tmpdir.mkdir("src"))
957+
958+
os.makedirs(pjoin(src_dir, "sub"))
959+
with open(pjoin(src_dir, "sub", "target"), "w") as f:
960+
f.write("hello")
961+
962+
os.symlink("sub/target", pjoin(src_dir, "safe"))
963+
os.symlink("./sub/target", pjoin(src_dir, "safe_dot"))
964+
os.symlink("/etc/passwd", pjoin(src_dir, "abs"))
965+
os.symlink("../../../etc/passwd", pjoin(src_dir, "dotdot"))
966+
os.symlink("sub/../../etc/passwd", pjoin(src_dir, "interleaved"))
967+
os.symlink("..", pjoin(src_dir, "bare_dotdot"))
968+
969+
with _sshfs_mount(src_dir, mnt_dir):
970+
# Safe symlinks pass through and resolve
971+
assert os.readlink(pjoin(mnt_dir, "safe")) == "sub/target"
972+
assert os.readlink(pjoin(mnt_dir, "safe_dot")) == "./sub/target"
973+
with open(pjoin(mnt_dir, "safe")) as f:
974+
assert f.read() == "hello"
975+
976+
# Dangerous: readlink returns EPERM
977+
for name in ("abs", "dotdot", "interleaved", "bare_dotdot"):
978+
with pytest.raises(OSError) as exc_info:
979+
os.readlink(pjoin(mnt_dir, name))
980+
assert exc_info.value.errno == errno.EPERM
981+
982+
# Dangerous: traversal (open/stat) also EPERM
983+
with pytest.raises(OSError) as exc_info:
984+
open(pjoin(mnt_dir, "abs"))
985+
assert exc_info.value.errno == errno.EPERM
986+
987+
with pytest.raises(OSError) as exc_info:
988+
os.stat(pjoin(mnt_dir, "dotdot"))
989+
assert exc_info.value.errno == errno.EPERM
990+
991+
992+
def test_no_contain_symlinks(tmpdir, capfd) -> None:
993+
"""Opt-out: symlinks pass through and actually resolve."""
994+
995+
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
996+
_check_ssh_localhost()
997+
998+
mnt_dir = str(tmpdir.mkdir("mnt"))
999+
src_dir = str(tmpdir.mkdir("src"))
1000+
1001+
os.symlink("/etc/passwd", pjoin(src_dir, "abs_link"))
1002+
os.symlink("../../../etc/passwd", pjoin(src_dir, "rel_escape"))
1003+
1004+
with _sshfs_mount(src_dir, mnt_dir, ["no_contain_symlinks"]):
1005+
assert os.readlink(pjoin(mnt_dir, "abs_link")) == "/etc/passwd"
1006+
assert os.readlink(pjoin(mnt_dir, "rel_escape")) == "../../../etc/passwd"
1007+
1008+
# Absolute symlink actually resolves (reads local /etc/passwd)
1009+
with open(pjoin(mnt_dir, "abs_link")) as f:
1010+
assert "root" in f.read()
1011+
1012+
# Relative escape: kernel must traverse the link (not EPERM).
1013+
# Target won't exist on the test host, so we just assert that
1014+
# sshfs didn't block it - any errno other than EPERM proves
1015+
# containment is genuinely disabled.
1016+
with pytest.raises(OSError) as exc_info:
1017+
os.stat(pjoin(mnt_dir, "rel_escape"))
1018+
assert exc_info.value.errno != errno.EPERM
1019+
1020+
1021+
def test_transform_with_contain(tmpdir, capfd) -> None:
1022+
"""transform_symlinks + default containment: transformed ../x is rejected."""
1023+
1024+
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
1025+
capfd.register_output(r"^warning: transform_symlinks.+", count=0)
1026+
_check_ssh_localhost()
1027+
1028+
mnt_dir = str(tmpdir.mkdir("mnt"))
1029+
src_dir = str(tmpdir.mkdir("src"))
1030+
1031+
os.makedirs(pjoin(src_dir, "other"))
1032+
with open(pjoin(src_dir, "other", "file"), "w") as f:
1033+
f.write("data")
1034+
# Absolute in-base: transform rewrites to "other/file" (no ..)
1035+
os.symlink(pjoin(src_dir, "other", "file"), pjoin(src_dir, "inbase"))
1036+
# Absolute in-base but sibling: transform rewrites to "../other/file"
1037+
os.makedirs(pjoin(src_dir, "sub"))
1038+
os.symlink(pjoin(src_dir, "other", "file"), pjoin(src_dir, "sub", "sibling"))
1039+
1040+
with _sshfs_mount(src_dir, mnt_dir, ["transform_symlinks"]):
1041+
# Direct child: transform produces "other/file" - no .., passes
1042+
link = os.readlink(pjoin(mnt_dir, "inbase"))
1043+
assert ".." not in link.split("/")
1044+
with open(pjoin(mnt_dir, "inbase")) as f:
1045+
assert f.read() == "data"
1046+
1047+
# Sibling: transform produces "../other/file" - has .., EPERM
1048+
with pytest.raises(OSError) as exc_info:
1049+
os.readlink(pjoin(mnt_dir, "sub", "sibling"))
1050+
assert exc_info.value.errno == errno.EPERM
1051+
1052+
# Same setup with no_contain_symlinks: sibling works
1053+
with _sshfs_mount(src_dir, mnt_dir,
1054+
["transform_symlinks", "no_contain_symlinks"]):
1055+
link = os.readlink(pjoin(mnt_dir, "sub", "sibling"))
1056+
assert ".." in link
1057+
with open(pjoin(mnt_dir, "sub", "sibling")) as f:
1058+
assert f.read() == "data"
1059+
1060+
1061+
def test_contain_symlinks_option_precedence(tmpdir, capfd) -> None:
1062+
"""Last option wins when contain_symlinks and no_contain_symlinks both set."""
1063+
1064+
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
1065+
_check_ssh_localhost()
1066+
1067+
mnt_dir = str(tmpdir.mkdir("mnt"))
1068+
src_dir = str(tmpdir.mkdir("src"))
1069+
1070+
os.symlink("/etc/passwd", pjoin(src_dir, "abs"))
1071+
1072+
# no_contain_symlinks last: containment disabled, readlink succeeds
1073+
with _sshfs_mount(src_dir, mnt_dir,
1074+
["contain_symlinks", "no_contain_symlinks"]):
1075+
assert os.readlink(pjoin(mnt_dir, "abs")) == "/etc/passwd"
1076+
1077+
# contain_symlinks last: containment enabled, EPERM
1078+
with _sshfs_mount(src_dir, mnt_dir,
1079+
["no_contain_symlinks", "contain_symlinks"]):
1080+
with pytest.raises(OSError) as exc_info:
1081+
os.readlink(pjoin(mnt_dir, "abs"))
1082+
assert exc_info.value.errno == errno.EPERM

0 commit comments

Comments
 (0)