Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package sshfs for openSUSE:Factory checked in at 2026-06-03 20:23:40 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/sshfs (Old) and /work/SRC/openSUSE:Factory/.sshfs.new.1937 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "sshfs" Wed Jun 3 20:23:40 2026 rev:43 rq:1356809 version:3.7.6 Changes: -------- --- /work/SRC/openSUSE:Factory/sshfs/sshfs.changes 2025-12-25 19:58:11.936591991 +0100 +++ /work/SRC/openSUSE:Factory/.sshfs.new.1937/sshfs.changes 2026-06-03 20:26:47.072068042 +0200 @@ -1,0 +2,19 @@ +Tue Jun 2 19:08:43 UTC 2026 - Matej Cepl <[email protected]> + +- Update to 3.7.6: + - Added new maintainer: abhinavagarwal07 Abhinav Agarwal + - CVE-2026-47187: Fixed critical vulnerability - Symlink + Escape: Rogue SFTP Server to Local File Read/Write), credit + to abhinavagarwal07 (bsc#1267017) + - New -o contain_symlinks and -o no_contain_symlinks to control + symlink containment behavior + - CVE-2026-48711: Fixed high severity vulnerability - Improper + Neutralization of Argument Delimiters in a Command ('Argument + Injection'), credit to abhinavagarwal07 (bsc#1267016) + - 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 + +------------------------------------------------------------------- Old: ---- sshfs-3.7.5.tar.xz sshfs-3.7.5.tar.xz.asc New: ---- sshfs-3.7.6.tar.xz sshfs-3.7.6.tar.xz.asc ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ sshfs.spec ++++++ --- /var/tmp/diff_new_pack.zDxViN/_old 2026-06-03 20:26:47.980105710 +0200 +++ /var/tmp/diff_new_pack.zDxViN/_new 2026-06-03 20:26:47.984105876 +0200 @@ -1,7 +1,7 @@ # # spec file for package sshfs # -# Copyright (c) 2025 SUSE LLC +# Copyright (c) 2026 SUSE LLC and contributors # Copyright (c) 2025 Andreas Stieger <[email protected]> # # All modifications and additions to the file contributed by third parties @@ -18,7 +18,7 @@ Name: sshfs -Version: 3.7.5 +Version: 3.7.6 Release: 0 Summary: Filesystem client based on SSH file transfer protocol License: GPL-2.0-or-later ++++++ sshfs-3.7.5.tar.xz -> sshfs-3.7.6.tar.xz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/.github/dependabot.yml new/sshfs-3.7.6/.github/dependabot.yml --- old/sshfs-3.7.5/.github/dependabot.yml 1970-01-01 01:00:00.000000000 +0100 +++ new/sshfs-3.7.6/.github/dependabot.yml 2026-05-30 01:35:39.000000000 +0200 @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/.github/workflows/build-platforms.yml new/sshfs-3.7.6/.github/workflows/build-platforms.yml --- old/sshfs-3.7.5/.github/workflows/build-platforms.yml 1970-01-01 01:00:00.000000000 +0100 +++ new/sshfs-3.7.6/.github/workflows/build-platforms.yml 2026-05-30 01:35:39.000000000 +0200 @@ -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/ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/.github/workflows/build-ubuntu.yml new/sshfs-3.7.6/.github/workflows/build-ubuntu.yml --- old/sshfs-3.7.5/.github/workflows/build-ubuntu.yml 2025-11-11 20:46:43.000000000 +0100 +++ new/sshfs-3.7.6/.github/workflows/build-ubuntu.yml 2026-05-30 01:35:39.000000000 +0200 @@ -7,45 +7,137 @@ workflow_dispatch: # this is a nice option that will enable a button w/ inputs inputs: git-ref: - description: Git Ref (Optional) + description: Git Ref (Optional) required: false + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-and-test: - name: Build and test - runs-on: ubuntu-latest + name: ${{ matrix.compiler }} / ${{ matrix.buildtype }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + buildtype: [debugoptimized, release] + include: + - compiler: gcc + cc: gcc + - compiler: clang + cc: clang steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-python@v4 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install valgrind gcc ninja-build meson libglib2.0-dev libfuse3-dev + sudo apt-get install -y gcc clang ninja-build libglib2.0-dev libfuse3-dev openssh-server openssh-client fuse3 - name: Install meson - run: pip3 install meson pytest + run: pip3 install meson pytest pytest-timeout - - name: build + - name: Print tool versions run: | - mkdir build; cd build - meson .. - ninja + ${{ matrix.cc }} --version + meson --version - # cd does not persist across steps - - name: upload build artifact - uses: actions/upload-artifact@v4 + - name: Setup SSH + run: | + 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 systemctl start ssh || sudo service ssh start + ssh -o StrictHostKeyChecking=no -o BatchMode=yes localhost true + + - name: Check FUSE availability + run: | + test -e /dev/fuse + command -v fusermount3 + + - name: Build + env: + CC: ${{ matrix.cc }} + run: | + meson setup build --buildtype=${{ matrix.buildtype }} -Dwerror=true + ninja -C build + + - name: Upload build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: sshfs + name: sshfs-${{ matrix.compiler }}-${{ matrix.buildtype }} path: build/sshfs + if-no-files-found: ignore + + - name: Run tests + timeout-minutes: 20 + run: | + cd build + python3 -m pytest --maxfail=99 --timeout=300 --junitxml=test-results.xml test/ - - name: make ssh into localhost without prompt possible for tests + - name: Upload test results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: test-results-${{ matrix.compiler }}-${{ matrix.buildtype }} + path: | + build/test-results.xml + build/meson-logs/ + + strict-warnings: + name: ${{ matrix.compiler }} / strict warnings + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - compiler: gcc + cc: gcc + extra_cflags: "-Wformat=2 -Wformat-security -Wundef -Wstrict-prototypes -Wold-style-definition -Wmissing-prototypes -Wnull-dereference" + - compiler: clang + cc: clang + extra_cflags: "-Wformat=2 -Wformat-security -Wundef -Wstrict-prototypes -Wold-style-definition -Wmissing-prototypes -Wnull-dereference" + steps: + - name: Checkout code + 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 build dependencies run: | - ssh-keygen -b 2048 -t rsa -f ~/.ssh/id_rsa -q -N "" - cat ~/.ssh/id_rsa.pub > ~/.ssh/authorized_keys + sudo apt-get update + sudo apt-get install -y gcc clang ninja-build libglib2.0-dev libfuse3-dev + + - name: Install meson + run: pip3 install meson - - name: run tests + - name: Print tool versions run: | - cd build - python3 -m pytest test/ + ${{ matrix.cc }} --version + meson --version + + - name: Build with strict warnings + env: + CC: ${{ matrix.cc }} + CFLAGS: ${{ matrix.extra_cflags }} + run: | + meson setup build -Dwerror=true + ninja -C build diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/AUTHORS new/sshfs-3.7.6/AUTHORS --- old/sshfs-3.7.5/AUTHORS 2025-11-11 20:46:43.000000000 +0100 +++ new/sshfs-3.7.6/AUTHORS 2026-05-30 01:35:39.000000000 +0200 @@ -1,7 +1,9 @@ -Current Maintainer +Current Maintainers ------------------ -None. +Haoxi Tan <[email protected]> +Abhinav Agarwal <[email protected]> + Past Maintainers diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/ChangeLog.rst new/sshfs-3.7.6/ChangeLog.rst --- old/sshfs-3.7.5/ChangeLog.rst 2025-11-11 20:46:43.000000000 +0100 +++ new/sshfs-3.7.6/ChangeLog.rst 2026-05-30 01:35:39.000000000 +0200 @@ -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) -------------------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/meson.build new/sshfs-3.7.6/meson.build --- old/sshfs-3.7.5/meson.build 2025-11-11 20:46:43.000000000 +0100 +++ new/sshfs-3.7.6/meson.build 2026-05-30 01:35:39.000000000 +0200 @@ -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' ]) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/sshfs.c new/sshfs-3.7.6/sshfs.c --- old/sshfs-3.7.5/sshfs.c 2025-11-11 20:46:43.000000000 +0100 +++ new/sshfs-3.7.6/sshfs.c 2026-05-30 01:35:39.000000000 +0200 @@ -280,6 +280,11 @@ struct sshfs_io sio; }; +struct conntab_entry { + unsigned refcount; + struct conn *conn; +}; + struct sshfs_file { struct buffer handle; struct list_head write_reqs; @@ -289,15 +294,11 @@ off_t next_pos; int is_seq; struct conn *conn; + struct conntab_entry *ce; int connver; int modifver; }; -struct conntab_entry { - unsigned refcount; - struct conn *conn; -}; - struct sshfs { char *directport; char *ssh_command; @@ -312,6 +313,7 @@ int fstat_workaround; int createmode_workaround; int transform_symlinks; + int contain_symlinks; int follow_symlinks; int no_check_root; int detect_uid; @@ -337,7 +339,6 @@ int sync_readdir; int direct_io; int debug; - int verbose; int foreground; int reconnect; int delay_connect; @@ -491,9 +492,10 @@ SSHFS_OPT("no_readahead", sync_read, 1), SSHFS_OPT("sync_readdir", sync_readdir, 1), SSHFS_OPT("sshfs_debug", debug, 1), - 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), @@ -513,8 +515,6 @@ SSHFS_OPT("--version", show_version, 1), SSHFS_OPT("-d", debug, 1), SSHFS_OPT("debug", debug, 1), - SSHFS_OPT("-v", verbose, 1), - SSHFS_OPT("verbose", verbose, 1), SSHFS_OPT("-f", foreground, 1), SSHFS_OPT("-s", singlethread, 1), @@ -1176,7 +1176,7 @@ perror("failed to redirect input/output"); _exit(1); } - if (!sshfs.verbose && !sshfs.foreground && devnull != -1) + if (!sshfs.foreground && devnull != -1) dup2(devnull, 2); close(devnull); @@ -1445,23 +1445,24 @@ static int sftp_read(struct conn *conn, uint8_t *type, struct buffer *buf) { - int res; + int res = -1; struct buffer buf2; uint32_t len; buf_init(&buf2, 5); - res = do_read(conn, &buf2); - if (res != -1) { - if (buf_get_uint32(&buf2, &len) == -1) - return -1; - if (len > MAX_REPLY_LEN) { - fprintf(stderr, "reply len too large: %u\n", len); - return -1; - } - if (buf_get_uint8(&buf2, type) == -1) - return -1; - buf_init(buf, len - 1); - res = do_read(conn, buf); + if (do_read(conn, &buf2) == -1) + goto out; + if (buf_get_uint32(&buf2, &len) == -1) + goto out; + if (len < 1 || len > MAX_REPLY_LEN) { + fprintf(stderr, "bad reply len: %u\n", len); + goto out; } + if (buf_get_uint8(&buf2, type) == -1) + goto out; + buf_init(buf, len - 1); + res = do_read(conn, buf); + +out: buf_free(&buf2); return res; } @@ -1534,10 +1535,14 @@ buf_init(&buf, 0); res = sftp_read(conn, &type, &buf); - if (res == -1) + if (res == -1) { + buf_free(&buf); return -1; - if (buf_get_uint32(&buf, &id) == -1) + } + if (buf_get_uint32(&buf, &id) == -1) { + buf_free(&buf); return -1; + } pthread_mutex_lock(&sshfs.lock); req = (struct request *) @@ -2173,6 +2178,36 @@ } 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; @@ -2237,6 +2272,13 @@ 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); @@ -2767,6 +2809,12 @@ return err; } +static gboolean conntab_entry_is(gpointer key, gpointer value, gpointer data) +{ + (void) key; + return value == data; +} + static int sshfs_open_common(const char *path, mode_t mode, struct fuse_file_info *fi) { @@ -2827,12 +2875,13 @@ g_hash_table_insert(sshfs.conntab, g_strdup(path), ce); } sf->conn = ce->conn; + sf->ce = ce; ce->refcount++; sf->conn->file_count++; assert(sf->conn->file_count > 0); } else { sf->conn = &sshfs.conns[0]; - ce = NULL; // only to silence compiler warning + sf->ce = NULL; } sf->connver = sf->conn->connver; pthread_mutex_unlock(&sshfs.lock); @@ -2872,10 +2921,12 @@ if (sshfs.max_conns > 1) { pthread_mutex_lock(&sshfs.lock); sf->conn->file_count--; - ce->refcount--; - if(ce->refcount == 0) { - g_hash_table_remove(sshfs.conntab, path); - g_free(ce); + sf->ce->refcount--; + if (sf->ce->refcount == 0) { + g_hash_table_foreach_remove(sshfs.conntab, + conntab_entry_is, + sf->ce); + g_free(sf->ce); } pthread_mutex_unlock(&sshfs.lock); } @@ -2946,7 +2997,6 @@ { struct sshfs_file *sf = get_sshfs_file(fi); struct buffer *handle = &sf->handle; - struct conntab_entry *ce; if (sshfs_file_is_conn(sf)) { sshfs_flush(path, fi); sftp_request(sf->conn, SSH_FXP_CLOSE, handle, 0, NULL); @@ -2954,12 +3004,13 @@ buf_free(handle); chunk_put_locked(sf->readahead); if (sshfs.max_conns > 1) { + struct conntab_entry *ce = sf->ce; pthread_mutex_lock(&sshfs.lock); sf->conn->file_count--; - ce = g_hash_table_lookup(sshfs.conntab, path); ce->refcount--; - if(ce->refcount == 0) { - g_hash_table_remove(sshfs.conntab, path); + if (ce->refcount == 0) { + g_hash_table_foreach_remove(sshfs.conntab, + conntab_entry_is, ce); g_free(ce); } pthread_mutex_unlock(&sshfs.lock); @@ -3386,7 +3437,7 @@ return sshfs_ext_statvfs(path, buf); buf->f_namemax = 255; - buf->f_bsize = sshfs.blksize; + buf->f_bsize = sshfs.blksize ? sshfs.blksize : 4096; /* * df seems to use f_bsize instead of f_frsize, so make them * the same @@ -3672,7 +3723,6 @@ " -o no_readahead synchronous reads (no speculative readahead)\n" " -o sync_readdir synchronous readdir\n" " -d, --debug print some debugging information (implies -f)\n" -" -v, --verbose print ssh replies and messages\n" " -o dir_cache=BOOL enable caching of directory contents (names,\n" " attributes, symlink targets) {yes,no} (default: yes)\n" " -o dcache_max_size=N sets the maximum size of the directory cache (default: 10000)\n" @@ -3710,6 +3760,9 @@ " -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" @@ -3944,7 +3997,7 @@ start = pos; - while (pos && *pos != '\0') { + while (*pos != '\0') { // break on space, but not on '\ ' if (*pos == ' ' && *(pos - 1) != '\\') { break; @@ -4009,6 +4062,11 @@ *d++ = '\0'; s++; + if (sshfs.host[0] == '-') { + fprintf(stderr, "invalid hostname '%s'\n", sshfs.host); + exit(1); + } + return s; } @@ -4267,6 +4325,7 @@ 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; @@ -4317,6 +4376,12 @@ 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) { @@ -4400,7 +4465,6 @@ 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) @@ -4411,6 +4475,8 @@ 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 -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/sshfs.rst new/sshfs-3.7.6/sshfs.rst --- old/sshfs-3.7.5/sshfs.rst 2025-11-11 20:46:43.000000000 +0100 +++ new/sshfs-3.7.6/sshfs.rst 2026-05-30 01:35:39.000000000 +0200 @@ -175,6 +175,21 @@ ``/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 @@ Authors ======= -SSHFS is currently maintained by Nikolaus Rath <[email protected]>, +SSHFS was maintained by Nikolaus Rath <[email protected]>, and was created by Miklos Szeredi <[email protected]>. +See https://github.com/libfuse/sshfs for new maintainer information. + This man page was originally written by Bartosz Fenski <[email protected]> for the Debian GNU/Linux distribution (but it may be used by others). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/test/meson.build new/sshfs-3.7.6/test/meson.build --- old/sshfs-3.7.5/test/meson.build 2025-11-11 20:46:43.000000000 +0100 +++ new/sshfs-3.7.6/test/meson.build 2026-05-30 01:35:39.000000000 +0200 @@ -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 -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/test/test_hostname_validation.py new/sshfs-3.7.6/test/test_hostname_validation.py --- old/sshfs-3.7.5/test/test_hostname_validation.py 1970-01-01 01:00:00.000000000 +0100 +++ new/sshfs-3.7.6/test/test_hostname_validation.py 2026-05-30 01:35:39.000000000 +0200 @@ -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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sshfs-3.7.5/test/test_sshfs.py new/sshfs-3.7.6/test/test_sshfs.py --- old/sshfs-3.7.5/test/test_sshfs.py 2025-11-11 20:46:43.000000000 +0100 +++ new/sshfs-3.7.6/test/test_sshfs.py 2026-05-30 01:35:39.000000000 +0200 @@ -15,6 +15,7 @@ import filecmp import errno from tempfile import NamedTemporaryFile +from contextlib import contextmanager from util import ( wait_for_mount, umount, @@ -123,6 +124,9 @@ # 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"] @@ -135,7 +139,7 @@ try: wait_for_mount(mount_process, mnt_dir) - tst_statvfs(mnt_dir) + tst_statvfs(src_dir, mnt_dir) tst_readdir(src_dir, mnt_dir) tst_open_read(src_dir, mnt_dir) tst_open_write(src_dir, mnt_dir) @@ -145,6 +149,10 @@ tst_passthrough(src_dir, mnt_dir, cache_timeout) tst_mkdir(mnt_dir) tst_rmdir(src_dir, mnt_dir, cache_timeout) + tst_rename(mnt_dir) + tst_rename_over(mnt_dir) + tst_chmod(mnt_dir) + tst_fsync(src_dir, mnt_dir) tst_unlink(src_dir, mnt_dir, cache_timeout) tst_symlink(mnt_dir) if os.getuid() == 0: @@ -159,6 +167,12 @@ tst_truncate_path(mnt_dir) tst_truncate_fd(mnt_dir) tst_open_unlink(mnt_dir) + tst_open_writeonly_read(mnt_dir) + tst_access(mnt_dir) + tst_mkdir_exist(mnt_dir) + tst_readdir_repeated(mnt_dir) + tst_rename_sibling(mnt_dir) + tst_rename_open_release(mnt_dir) except Exception as exc: cleanup(mount_process, mnt_dir) raise exc @@ -208,6 +222,83 @@ assert name not in os.listdir(src_dir) +def tst_rename(mnt_dir): + src_name = pjoin(mnt_dir, name_generator()) + dst_name = pjoin(mnt_dir, name_generator()) + data = b"rename test data\n" + + with open(src_name, "wb") as fh: + fh.write(data) + assert os.path.exists(src_name) + + os.rename(src_name, dst_name) + + assert not os.path.exists(src_name) + assert os.path.basename(src_name) not in os.listdir(mnt_dir) + assert os.path.basename(dst_name) in os.listdir(mnt_dir) + with open(dst_name, "rb") as fh: + assert fh.read() == data + + os.unlink(dst_name) + + +def tst_rename_over(mnt_dir): + src_name = pjoin(mnt_dir, name_generator()) + dst_name = pjoin(mnt_dir, name_generator()) + src_data = b"source content\n" + dst_data = b"destination content\n" + + with open(src_name, "wb") as fh: + fh.write(src_data) + with open(dst_name, "wb") as fh: + fh.write(dst_data) + + os.rename(src_name, dst_name) + + assert not os.path.exists(src_name) + assert os.path.basename(src_name) not in os.listdir(mnt_dir) + with open(dst_name, "rb") as fh: + assert fh.read() == src_data + + os.unlink(dst_name) + + +def tst_chmod(mnt_dir): + filename = pjoin(mnt_dir, name_generator()) + with open(filename, "wb") as fh: + fh.write(b"chmod test\n") + + os.chmod(filename, 0o644) + fstat = os.stat(filename) + assert stat.S_IMODE(fstat.st_mode) == 0o644 + + os.chmod(filename, 0o755) + fstat = os.stat(filename) + assert stat.S_IMODE(fstat.st_mode) == 0o755 + + os.unlink(filename) + + +def tst_fsync(src_dir, mnt_dir): + name = name_generator() + mnt_name = pjoin(mnt_dir, name) + src_name = pjoin(src_dir, name) + data = b"fsync test data\n" + + fd = os.open(mnt_name, os.O_CREAT | os.O_WRONLY) + try: + os.write(fd, data) + os.fsync(fd) + # Read from backing store while fd is still open, before + # close/release has a chance to flush + with open(src_name, "rb") as fh: + assert fh.read() == data + finally: + os.close(fd) + + os.unlink(mnt_name) + + def tst_symlink(mnt_dir): linkname = name_generator() fullname = mnt_dir + "/" + linkname @@ -218,6 +309,15 @@ 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) + def tst_create(mnt_dir): name = name_generator() @@ -314,15 +414,121 @@ os.unlink(fullname) with pytest.raises(OSError) as exc_info: os.stat(fullname) - assert exc_info.value.errno == errno.ENOENT + assert exc_info.value.errno == errno.ENOENT assert name not in os.listdir(mnt_dir) fh.write(data2) fh.seek(0) assert fh.read() == data1 + data2 -def tst_statvfs(mnt_dir): - os.statvfs(mnt_dir) +def tst_statvfs(src_dir, mnt_dir): + vfs = os.statvfs(mnt_dir) + ref = os.statvfs(src_dir) + # When the server supports [email protected], values should + # match the backing store. Otherwise sshfs returns synthetic + # values that still pass the loose checks. + if vfs.f_bsize == ref.f_bsize: + assert vfs.f_frsize == ref.f_frsize + assert vfs.f_blocks == ref.f_blocks + assert vfs.f_namemax == ref.f_namemax + else: + assert vfs.f_bsize > 0 + assert vfs.f_blocks > 0 + assert vfs.f_namemax > 0 + + +def tst_open_writeonly_read(mnt_dir): + name = pjoin(mnt_dir, name_generator()) + fd = os.open(name, os.O_CREAT | os.O_WRONLY) + try: + os.write(fd, b"hello") + with pytest.raises(OSError) as exc_info: + os.read(fd, 10) + assert exc_info.value.errno == errno.EBADF + finally: + os.close(fd) + os.unlink(name) + + +def tst_access(mnt_dir): + filename = pjoin(mnt_dir, name_generator()) + with open(filename, "wb") as fh: + fh.write(b"test") + os.chmod(filename, 0o644) + assert os.access(filename, os.R_OK) + if os.getuid() != 0: + assert not os.access(filename, os.X_OK) + os.unlink(filename) + + +def tst_mkdir_exist(mnt_dir): + name = name_generator() + fullname = pjoin(mnt_dir, name) + os.mkdir(fullname) + with pytest.raises(OSError) as exc_info: + os.mkdir(fullname) + assert exc_info.value.errno == errno.EEXIST + os.rmdir(fullname) + + +def tst_readdir_repeated(mnt_dir): + dirname = pjoin(mnt_dir, name_generator()) + os.mkdir(dirname) + names = [] + for i in range(5): + n = name_generator() + names.append(n) + with open(pjoin(dirname, n), "wb") as fh: + fh.write(b"x") + + # Verify repeated directory listings return consistent results + listing1 = sorted(os.listdir(dirname)) + listing2 = sorted(os.listdir(dirname)) + assert listing1 == sorted(names) + assert listing1 == listing2 + + for n in names: + os.unlink(pjoin(dirname, n)) + os.rmdir(dirname) + + +def tst_rename_sibling(mnt_dir): + # Verify renaming one file doesn't break access to a sibling + name_a = pjoin(mnt_dir, name_generator()) + name_b = pjoin(mnt_dir, name_generator()) + name_c = pjoin(mnt_dir, name_generator()) + + with open(name_a, "wb") as fh: + fh.write(b"aaa") + with open(name_b, "wb") as fh: + fh.write(b"bbb") + + os.rename(name_a, name_c) + + assert not os.path.exists(name_a) + assert os.path.exists(name_b) + with open(name_b, "rb") as fh: + assert fh.read() == b"bbb" + + os.unlink(name_b) + os.unlink(name_c) + + +def tst_rename_open_release(mnt_dir): + src = pjoin(mnt_dir, name_generator()) + dst = pjoin(mnt_dir, name_generator()) + + fd = os.open(src, os.O_CREAT | os.O_RDWR) + try: + os.write(fd, b"data") + os.rename(src, dst) + finally: + os.close(fd) + + assert not os.path.exists(src) + with open(dst, "rb") as fh: + assert fh.read() == b"data" + os.unlink(dst) def tst_link(mnt_dir, cache_timeout): @@ -365,6 +571,10 @@ assert os.path.basename(name2) not in os.listdir(mnt_dir) with pytest.raises(FileNotFoundError): os.lstat(name2) + if cache_timeout: + safe_sleep(cache_timeout + 1) + fstat1 = os.lstat(name1) + assert fstat1.st_nlink == 1 os.unlink(name1) @@ -418,6 +628,10 @@ with open(filename, "rb") as fh: assert fh.read(size) == TEST_DATA[: size - 1024] + # Truncate to zero + os.truncate(filename, 0) + assert os.stat(filename).st_size == 0 + os.unlink(filename) @@ -443,6 +657,10 @@ fh.seek(0) assert fh.read(size) == TEST_DATA[: size - 1024] + # Truncate to zero via fd + os.ftruncate(fd, 0) + assert os.fstat(fd).st_size == 0 + def tst_utimens(mnt_dir, tol=0): filename = pjoin(mnt_dir, name_generator()) @@ -483,7 +701,7 @@ def tst_passthrough(src_dir, mnt_dir, cache_timeout): name = name_generator() src_name = pjoin(src_dir, name) - mnt_name = pjoin(src_dir, name) + mnt_name = pjoin(mnt_dir, name) assert name not in os.listdir(src_dir) assert name not in os.listdir(mnt_dir) with open(src_name, "w") as fh: @@ -492,11 +710,16 @@ if cache_timeout: safe_sleep(cache_timeout + 1) assert name in os.listdir(mnt_dir) - assert os.stat(src_name) == os.stat(mnt_name) + src_st = os.stat(src_name) + mnt_st = os.stat(mnt_name) + assert src_st.st_size == mnt_st.st_size + assert src_st.st_uid == mnt_st.st_uid + assert src_st.st_gid == mnt_st.st_gid + assert abs(src_st.st_mtime - mnt_st.st_mtime) <= 1 name = name_generator() src_name = pjoin(src_dir, name) - mnt_name = pjoin(src_dir, name) + mnt_name = pjoin(mnt_dir, name) assert name not in os.listdir(src_dir) assert name not in os.listdir(mnt_dir) with open(mnt_name, "w") as fh: @@ -505,4 +728,355 @@ if cache_timeout: safe_sleep(cache_timeout + 1) assert name in os.listdir(mnt_dir) - assert os.stat(src_name) == os.stat(mnt_name) + src_st = os.stat(src_name) + mnt_st = os.stat(mnt_name) + assert src_st.st_size == mnt_st.st_size + assert src_st.st_uid == mnt_st.st_uid + assert src_st.st_gid == mnt_st.st_gid + assert abs(src_st.st_mtime - mnt_st.st_mtime) <= 1 + + +def _check_ssh_localhost(): + try: + res = subprocess.call( + ["ssh", "-o", "StrictHostKeyChecking=no", + "-o", "KbdInteractiveAuthentication=no", + "-o", "ChallengeResponseAuthentication=no", + "-o", "PasswordAuthentication=no", + "localhost", "--", "true"], + stdin=subprocess.DEVNULL, timeout=10, + ) + except subprocess.TimeoutExpired: + res = 1 + if res != 0: + pytest.fail("Unable to ssh into localhost without password prompt.") + + +_mount_ctr = [0] + + +def _mount_sshfs(tmpdir, extra_opts=None): + """Helper to mount sshfs with custom options. Returns (mount_process, mnt_dir, src_dir).""" + _check_ssh_localhost() + _mount_ctr[0] += 1 + mnt_dir = str(tmpdir.mkdir(f"mnt{_mount_ctr[0]}")) + src_dir = str(tmpdir.mkdir(f"src{_mount_ctr[0]}")) + + 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) + except: + cleanup(mount_process, mnt_dir) + raise + return mount_process, mnt_dir, src_dir + + +def test_disable_hardlink(tmpdir, capfd): + capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0) + + # Control: verify hardlinks work without disable_hardlink. + # If the server lacks the extension, skip this test entirely. + mount_process, mnt_dir, src_dir = _mount_sshfs(tmpdir, []) + try: + name1 = pjoin(mnt_dir, name_generator()) + name2 = pjoin(mnt_dir, name_generator()) + with open(name1, "wb") as fh: + fh.write(b"test") + try: + os.link(name1, name2) + except OSError: + os.unlink(name1) + pytest.skip("server does not support hardlink extension") + os.unlink(name2) + os.unlink(name1) + except Exception: + cleanup(mount_process, mnt_dir) + raise + else: + umount(mount_process, mnt_dir) + + # Now test with disable_hardlink — links should fail + mount_process, mnt_dir, src_dir = _mount_sshfs(tmpdir, ["disable_hardlink"]) + try: + name1 = pjoin(mnt_dir, name_generator()) + name2 = pjoin(mnt_dir, name_generator()) + with open(name1, "wb") as fh: + fh.write(b"test") + with pytest.raises(OSError) as exc_info: + os.link(name1, name2) + assert exc_info.value.errno in (errno.ENOSYS, errno.EPERM) + os.unlink(name1) + except Exception: + cleanup(mount_process, mnt_dir) + raise + else: + umount(mount_process, mnt_dir) + + +def test_follow_symlinks(tmpdir, capfd): + capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0) + mount_process, mnt_dir, src_dir = _mount_sshfs(tmpdir, ["follow_symlinks"]) + try: + target_name = name_generator() + target = pjoin(src_dir, target_name) + with open(target, "wb") as fh: + fh.write(b"symlink target data") + + link = pjoin(src_dir, name_generator()) + os.symlink(target_name, link) + + mnt_link = pjoin(mnt_dir, os.path.basename(link)) + # With follow_symlinks, stat should return the target's attributes + # and the entry should appear as a regular file, not a symlink + fstat = os.lstat(mnt_link) + assert stat.S_ISREG(fstat.st_mode) + with open(mnt_link, "rb") as fh: + assert fh.read() == b"symlink target data" + + os.unlink(link) + os.unlink(target) + except Exception: + cleanup(mount_process, mnt_dir) + raise + else: + umount(mount_process, mnt_dir) + + +def test_direct_io(tmpdir, capfd): + capfd.register_output(r"^Warning: Permanently added 'localhost' .+", count=0) + mount_process, mnt_dir, src_dir = _mount_sshfs(tmpdir, ["direct_io"]) + try: + name = name_generator() + mnt_name = pjoin(mnt_dir, name) + src_name = pjoin(src_dir, name) + data = b"direct io test data\n" * 100 + + with open(mnt_name, "wb") as fh: + fh.write(data) + with open(mnt_name, "rb") as fh: + assert fh.read() == data + with open(src_name, "rb") as fh: + assert fh.read() == data + + os.unlink(mnt_name) + except Exception: + cleanup(mount_process, mnt_dir) + raise + else: + umount(mount_process, mnt_dir) + + +def test_bad_sftp_reply_len(tmpdir): + """sshfs must reject a zero-length SFTP reply instead of underflowing.""" + helper = tmpdir.join("bad_sftp.py") + helper.write( + '#!/usr/bin/env python3\n' + 'import os, struct, sys\n' + 'def read_pkt():\n' + ' hdr = os.read(0, 4)\n' + ' if len(hdr) < 4: sys.exit(0)\n' + ' n = struct.unpack(">I", hdr)[0]\n' + ' while n:\n' + ' c = os.read(0, n)\n' + ' if not c: sys.exit(0)\n' + ' n -= len(c)\n' + 'read_pkt()\n' + 'os.write(1, struct.pack(">IBI", 5, 2, 3))\n' # SSH_FXP_VERSION v3 + 'read_pkt()\n' + 'os.write(1, struct.pack(">IB", 0, 0))\n' # len=0 reply (5 bytes on wire) + ) + helper.chmod(0o755) + + mnt_dir = str(tmpdir.mkdir("mnt")) + cmdline = base_cmdline + [ + pjoin(basename, "sshfs"), + "-f", + "dummy:/", + mnt_dir, + "-o", f"ssh_command={helper}", + ] + res = subprocess.run( + cmdline, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + text=True, + ) + 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
