Compare commits
9 Commits
ci-valgrind
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bf7f92cd8 | |||
| 7a2d988775 | |||
| 879f15a83b | |||
| 6678accb85 | |||
| 6893c3a51b | |||
| 29bb565ea6 | |||
| b6b23b7482 | |||
| bcd132f17c | |||
| 8711fa13a2 |
@@ -4,6 +4,8 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 14
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
name: platform builds
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux-compat:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: ubuntu-latest
|
||||
runner: ubuntu-latest
|
||||
run_tests: true
|
||||
- name: ubuntu-22.04
|
||||
runner: ubuntu-22.04
|
||||
run_tests: true
|
||||
- name: ubuntu-24.04-arm
|
||||
runner: ubuntu-24.04-arm
|
||||
run_tests: true
|
||||
- name: ubuntu-24.04-release
|
||||
runner: ubuntu-24.04
|
||||
buildtype: release
|
||||
- name: ubuntu-24.04-debug
|
||||
runner: ubuntu-24.04
|
||||
buildtype: debug
|
||||
- name: ubuntu-24.04-hardened
|
||||
runner: ubuntu-24.04
|
||||
extra_cflags: "-D_FORTIFY_SOURCE=3 -fstack-protector-strong"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc ninja-build pkg-config libglib2.0-dev libfuse3-dev ${{ matrix.run_tests && 'openssh-server openssh-client fuse3' || '' }}
|
||||
pip3 install meson ${{ matrix.run_tests && 'pytest pytest-timeout' || '' }}
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CFLAGS: ${{ matrix.extra_cflags || '' }}
|
||||
run: |
|
||||
meson setup build ${{ matrix.buildtype && format('--buildtype={0}', matrix.buildtype) || '' }}
|
||||
ninja -C build
|
||||
|
||||
- name: Setup SSH
|
||||
if: matrix.run_tests
|
||||
run: |
|
||||
chmod go-w ~
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -q -N ""
|
||||
cat ~/.ssh/id_ed25519.pub > ~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
sudo sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
|
||||
sudo systemctl restart ssh || sudo service ssh restart
|
||||
ssh -o StrictHostKeyChecking=no -o BatchMode=yes localhost true
|
||||
|
||||
- name: Check FUSE availability
|
||||
if: matrix.run_tests
|
||||
run: |
|
||||
test -e /dev/fuse
|
||||
command -v fusermount3
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.run_tests
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
cd build
|
||||
python3 -m pytest test/ --timeout=300 --maxfail=99 --junitxml=test-results.xml
|
||||
|
||||
- name: Upload test results
|
||||
if: matrix.run_tests && always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-${{ matrix.name }}
|
||||
path: |
|
||||
build/test-results.xml
|
||||
build/meson-logs/
|
||||
|
||||
- name: Upload build logs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: build-logs-${{ matrix.name }}
|
||||
path: build/meson-logs/
|
||||
|
||||
alpine-musl:
|
||||
name: alpine-musl
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Build in Alpine container
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/work" -w /work alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d sh -euxc '
|
||||
apk add --no-cache gcc musl-dev meson ninja pkgconf glib-dev fuse3-dev
|
||||
meson setup build-alpine
|
||||
ninja -C build-alpine
|
||||
'
|
||||
|
||||
- name: Upload build logs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: build-logs-alpine-musl
|
||||
path: build-alpine/meson-logs/
|
||||
|
||||
freebsd:
|
||||
name: freebsd-14
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Build on FreeBSD
|
||||
uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
|
||||
with:
|
||||
usesh: true
|
||||
prepare: |
|
||||
pkg install -y fusefs-libs3 glib meson ninja pkgconf
|
||||
run: |
|
||||
meson setup build-freebsd
|
||||
ninja -C build-freebsd
|
||||
|
||||
- name: Upload build logs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: build-logs-freebsd
|
||||
path: build-freebsd/meson-logs/
|
||||
@@ -1,7 +1,9 @@
|
||||
Current Maintainer
|
||||
Current Maintainers
|
||||
------------------
|
||||
|
||||
None.
|
||||
Haoxi Tan <h4sh5@pm.me>
|
||||
Abhinav Agarwal <abhinavagarwal1996@gmail.com>
|
||||
|
||||
|
||||
|
||||
Past Maintainers
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
Release 3.7.6 (2026-05-30)
|
||||
--------------------------
|
||||
|
||||
* Added new maintainer: abhinavagarwal07 Abhinav Agarwal
|
||||
* Fixed critical vulnerability CVE-2026-47187 - Symlink Escape: Rogue SFTP Server to Local File Read/Write), credit to abhinavagarwal07
|
||||
* New -o contain_symlinks and -o no_contain_symlinks to control symlink containment behavior
|
||||
* Fixed high severity vulnerability CVE-2026-48711 - Improper Neutralization of Argument Delimiters in a Command ('Argument Injection'), credit to abhinavagarwal07
|
||||
* Fixed null-deref warning in tokenize_on_space, promote strict-warnings to required
|
||||
* Added a number of tests in CI, including rename, chmod, fsync, statvfs values, error paths, option coverage
|
||||
* Fixed malformed SFTP reply handling
|
||||
* Hardened Github Actions workflow with SHA pins, permissions, timeouts, and dependabot
|
||||
|
||||
Release 3.7.5 (2025-11-11)
|
||||
--------------------------
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
project('sshfs', 'c', version: '3.7.5',
|
||||
project('sshfs', 'c', version: '3.7.6',
|
||||
meson_version: '>= 0.40',
|
||||
default_options: [ 'buildtype=debugoptimized' ])
|
||||
|
||||
|
||||
@@ -313,6 +313,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_debug", debug, 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),
|
||||
@@ -2175,6 +2178,36 @@ static void strip_common(const char **sp, const char **tp)
|
||||
} 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;
|
||||
@@ -2239,6 +2272,13 @@ static int sshfs_readlink(const char *path, char *linkbuf, size_t size)
|
||||
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);
|
||||
@@ -3720,6 +3760,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"
|
||||
@@ -4019,6 +4062,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;
|
||||
}
|
||||
|
||||
@@ -4277,6 +4325,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;
|
||||
@@ -4327,6 +4376,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) {
|
||||
@@ -4410,7 +4465,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 +4475,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);
|
||||
|
||||
|
||||
@@ -175,6 +175,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
|
||||
@@ -338,9 +353,11 @@ https://github.com/libfuse/libfuse/issues.
|
||||
Authors
|
||||
=======
|
||||
|
||||
SSHFS is currently maintained by Nikolaus Rath <Nikolaus@rath.org>,
|
||||
SSHFS was maintained by Nikolaus Rath <Nikolaus@rath.org>,
|
||||
and was created by Miklos Szeredi <miklos@szeredi.hu>.
|
||||
|
||||
See https://github.com/libfuse/sshfs for new maintainer information.
|
||||
|
||||
This man page was originally written by Bartosz Fenski
|
||||
<fenio@debian.org> for the Debian GNU/Linux distribution (but it may
|
||||
be used by others).
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -15,6 +15,7 @@ import shutil
|
||||
import filecmp
|
||||
import errno
|
||||
from tempfile import NamedTemporaryFile
|
||||
from contextlib import contextmanager
|
||||
from util import (
|
||||
wait_for_mount,
|
||||
umount,
|
||||
@@ -123,6 +124,9 @@ def test_sshfs(
|
||||
# FUSE Cache
|
||||
cmdline += ["-o", "entry_timeout=0", "-o", "attr_timeout=0"]
|
||||
|
||||
# Disable containment so tst_symlink can test absolute targets
|
||||
cmdline += ["-o", "no_contain_symlinks"]
|
||||
|
||||
if multiconn:
|
||||
cmdline += ["-o", "max_conns=3"]
|
||||
|
||||
@@ -305,6 +309,12 @@ def tst_symlink(mnt_dir):
|
||||
assert fstat.st_nlink == 1
|
||||
assert linkname in os.listdir(mnt_dir)
|
||||
|
||||
# Relative symlink without .. should also work
|
||||
linkname2 = name_generator()
|
||||
fullname2 = mnt_dir + "/" + linkname2
|
||||
os.symlink("subdir/file", fullname2)
|
||||
assert os.readlink(fullname2) == "subdir/file"
|
||||
|
||||
os.unlink(fullname)
|
||||
assert linkname not in os.listdir(mnt_dir)
|
||||
|
||||
@@ -910,3 +920,163 @@ def test_bad_sftp_reply_len(tmpdir):
|
||||
)
|
||||
assert res.returncode != 0
|
||||
assert "bad reply len: 0" in res.stderr
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _sshfs_mount(src_dir, mnt_dir, extra_opts=None):
|
||||
"""Mount src_dir via sshfs, yield, then unmount."""
|
||||
cmdline = base_cmdline + [
|
||||
pjoin(basename, "sshfs"), "-f",
|
||||
f"localhost:{src_dir}", mnt_dir,
|
||||
"-o", "entry_timeout=0", "-o", "attr_timeout=0",
|
||||
]
|
||||
if extra_opts:
|
||||
for opt in extra_opts:
|
||||
cmdline += ["-o", opt]
|
||||
new_env = dict(os.environ)
|
||||
new_env["G_DEBUG"] = "fatal-warnings"
|
||||
mount_process = subprocess.Popen(cmdline, env=new_env)
|
||||
try:
|
||||
wait_for_mount(mount_process, mnt_dir)
|
||||
yield mnt_dir
|
||||
except Exception:
|
||||
cleanup(mount_process, mnt_dir)
|
||||
raise
|
||||
else:
|
||||
umount(mount_process, mnt_dir)
|
||||
|
||||
|
||||
def test_contain_symlinks(tmpdir, capfd) -> None:
|
||||
"""Default containment: safe symlinks resolve, dangerous ones get EPERM."""
|
||||
|
||||
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
|
||||
_check_ssh_localhost()
|
||||
|
||||
mnt_dir = str(tmpdir.mkdir("mnt"))
|
||||
src_dir = str(tmpdir.mkdir("src"))
|
||||
|
||||
os.makedirs(pjoin(src_dir, "sub"))
|
||||
with open(pjoin(src_dir, "sub", "target"), "w") as f:
|
||||
f.write("hello")
|
||||
|
||||
os.symlink("sub/target", pjoin(src_dir, "safe"))
|
||||
os.symlink("./sub/target", pjoin(src_dir, "safe_dot"))
|
||||
os.symlink("/etc/passwd", pjoin(src_dir, "abs"))
|
||||
os.symlink("../../../etc/passwd", pjoin(src_dir, "dotdot"))
|
||||
os.symlink("sub/../../etc/passwd", pjoin(src_dir, "interleaved"))
|
||||
os.symlink("..", pjoin(src_dir, "bare_dotdot"))
|
||||
|
||||
with _sshfs_mount(src_dir, mnt_dir):
|
||||
# Safe symlinks pass through and resolve
|
||||
assert os.readlink(pjoin(mnt_dir, "safe")) == "sub/target"
|
||||
assert os.readlink(pjoin(mnt_dir, "safe_dot")) == "./sub/target"
|
||||
with open(pjoin(mnt_dir, "safe")) as f:
|
||||
assert f.read() == "hello"
|
||||
|
||||
# Dangerous: readlink returns EPERM
|
||||
for name in ("abs", "dotdot", "interleaved", "bare_dotdot"):
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
os.readlink(pjoin(mnt_dir, name))
|
||||
assert exc_info.value.errno == errno.EPERM
|
||||
|
||||
# Dangerous: traversal (open/stat) also EPERM
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
open(pjoin(mnt_dir, "abs"))
|
||||
assert exc_info.value.errno == errno.EPERM
|
||||
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
os.stat(pjoin(mnt_dir, "dotdot"))
|
||||
assert exc_info.value.errno == errno.EPERM
|
||||
|
||||
|
||||
def test_no_contain_symlinks(tmpdir, capfd) -> None:
|
||||
"""Opt-out: symlinks pass through and actually resolve."""
|
||||
|
||||
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
|
||||
_check_ssh_localhost()
|
||||
|
||||
mnt_dir = str(tmpdir.mkdir("mnt"))
|
||||
src_dir = str(tmpdir.mkdir("src"))
|
||||
|
||||
os.symlink("/etc/passwd", pjoin(src_dir, "abs_link"))
|
||||
os.symlink("../../../etc/passwd", pjoin(src_dir, "rel_escape"))
|
||||
|
||||
with _sshfs_mount(src_dir, mnt_dir, ["no_contain_symlinks"]):
|
||||
assert os.readlink(pjoin(mnt_dir, "abs_link")) == "/etc/passwd"
|
||||
assert os.readlink(pjoin(mnt_dir, "rel_escape")) == "../../../etc/passwd"
|
||||
|
||||
# Absolute symlink actually resolves (reads local /etc/passwd)
|
||||
with open(pjoin(mnt_dir, "abs_link")) as f:
|
||||
assert "root" in f.read()
|
||||
|
||||
# Relative escape: kernel must traverse the link (not EPERM).
|
||||
# Target won't exist on the test host, so we just assert that
|
||||
# sshfs didn't block it - any errno other than EPERM proves
|
||||
# containment is genuinely disabled.
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
os.stat(pjoin(mnt_dir, "rel_escape"))
|
||||
assert exc_info.value.errno != errno.EPERM
|
||||
|
||||
|
||||
def test_transform_with_contain(tmpdir, capfd) -> None:
|
||||
"""transform_symlinks + default containment: transformed ../x is rejected."""
|
||||
|
||||
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
|
||||
capfd.register_output(r"^warning: transform_symlinks.+", count=0)
|
||||
_check_ssh_localhost()
|
||||
|
||||
mnt_dir = str(tmpdir.mkdir("mnt"))
|
||||
src_dir = str(tmpdir.mkdir("src"))
|
||||
|
||||
os.makedirs(pjoin(src_dir, "other"))
|
||||
with open(pjoin(src_dir, "other", "file"), "w") as f:
|
||||
f.write("data")
|
||||
# Absolute in-base: transform rewrites to "other/file" (no ..)
|
||||
os.symlink(pjoin(src_dir, "other", "file"), pjoin(src_dir, "inbase"))
|
||||
# Absolute in-base but sibling: transform rewrites to "../other/file"
|
||||
os.makedirs(pjoin(src_dir, "sub"))
|
||||
os.symlink(pjoin(src_dir, "other", "file"), pjoin(src_dir, "sub", "sibling"))
|
||||
|
||||
with _sshfs_mount(src_dir, mnt_dir, ["transform_symlinks"]):
|
||||
# Direct child: transform produces "other/file" - no .., passes
|
||||
link = os.readlink(pjoin(mnt_dir, "inbase"))
|
||||
assert ".." not in link.split("/")
|
||||
with open(pjoin(mnt_dir, "inbase")) as f:
|
||||
assert f.read() == "data"
|
||||
|
||||
# Sibling: transform produces "../other/file" - has .., EPERM
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
os.readlink(pjoin(mnt_dir, "sub", "sibling"))
|
||||
assert exc_info.value.errno == errno.EPERM
|
||||
|
||||
# Same setup with no_contain_symlinks: sibling works
|
||||
with _sshfs_mount(src_dir, mnt_dir,
|
||||
["transform_symlinks", "no_contain_symlinks"]):
|
||||
link = os.readlink(pjoin(mnt_dir, "sub", "sibling"))
|
||||
assert ".." in link
|
||||
with open(pjoin(mnt_dir, "sub", "sibling")) as f:
|
||||
assert f.read() == "data"
|
||||
|
||||
|
||||
def test_contain_symlinks_option_precedence(tmpdir, capfd) -> None:
|
||||
"""Last option wins when contain_symlinks and no_contain_symlinks both set."""
|
||||
|
||||
capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0)
|
||||
_check_ssh_localhost()
|
||||
|
||||
mnt_dir = str(tmpdir.mkdir("mnt"))
|
||||
src_dir = str(tmpdir.mkdir("src"))
|
||||
|
||||
os.symlink("/etc/passwd", pjoin(src_dir, "abs"))
|
||||
|
||||
# no_contain_symlinks last: containment disabled, readlink succeeds
|
||||
with _sshfs_mount(src_dir, mnt_dir,
|
||||
["contain_symlinks", "no_contain_symlinks"]):
|
||||
assert os.readlink(pjoin(mnt_dir, "abs")) == "/etc/passwd"
|
||||
|
||||
# contain_symlinks last: containment enabled, EPERM
|
||||
with _sshfs_mount(src_dir, mnt_dir,
|
||||
["no_contain_symlinks", "contain_symlinks"]):
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
os.readlink(pjoin(mnt_dir, "abs"))
|
||||
assert exc_info.value.errno == errno.EPERM
|
||||
|
||||
Reference in New Issue
Block a user