Compare commits

...

21 Commits

Author SHA1 Message Date
h4sh 8bf7f92cd8 introduce dependency cooldown 2026-06-07 12:18:20 +10:00
h4sh 7a2d988775 Merge pull request #363 from libfuse/3.7.6-release
prep for 3.7.6
2026-05-30 09:35:39 +10:00
h4sh5 879f15a83b prep for 3.7.6 2026-05-30 09:22:08 +10:00
h4sh 6678accb85 Merge pull request #362 from abhinavagarwal07/advisory-fix-1
reject hostname option injection via bracketed mount source
2026-05-30 09:12:11 +10:00
Abhinav Agarwal 6893c3a51b Merge pull request #361 from abhinavagarwal07/contain-symlinks
add contain_symlinks option to prevent symlink escape attacks
2026-05-29 16:06:54 -07:00
Abhinav Agarwal 29bb565ea6 reject hostname option injection via bracketed mount source
A source like [-oProxyCommand=CMD]:/path passes the bracket-parsing
check in find_base_path() and ends up as -oProxyCommand=CMD in the
ssh argv.  When sftp_server is a path, ssh gets a destination argument
and executes the injected ProxyCommand before connecting.

Reject hostnames starting with - after bracket stripping, and add --
before the hostname in the ssh command line so positional args can't
be misread as options.
2026-05-29 16:00:27 -07:00
h4sh b6b23b7482 Merge pull request #358 from abhinavagarwal07/ci-platform-coverage
ci: add platform build coverage
2026-05-30 08:56:57 +10:00
Abhinav Agarwal bcd132f17c add contain_symlinks option to prevent symlink escape attacks
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.
2026-05-29 15:54:21 -07:00
Abhinav Agarwal 8711fa13a2 ci: add platform build coverage 2026-05-22 10:21:17 -07:00
Abhinav Agarwal 25b58aad4d Merge pull request #359 from libfuse/fix-null-deref-strict-warnings
fix null-deref warning in tokenize_on_space, promote strict-warnings to required
2026-05-21 17:28:01 -07:00
Abhinav Agarwal c71eea8e68 fix null-deref warning in tokenize_on_space and promote strict-warnings to required 2026-05-20 23:48:08 -07:00
Abhinav Agarwal afbdf92fa6 Merge pull request #353 from abhinavagarwal07/ci-werror-clang
ci: add gcc/clang matrix with -Werror
2026-05-20 23:41:00 -07:00
Abhinav Agarwal daac34f7c8 ci: add gcc/clang matrix with -Werror
- Expand build job into gcc/clang matrix with fail-fast: false
- Pass -Dwerror=true so compiler warnings fail the build
- Pin all actions to Node 24-capable full SHAs (checkout v6.0.2, setup-python v6.2.0, upload-artifact v7.0.1)
- Add least-privilege permissions, concurrency cancellation
- Pin python-version to 3.12, pin runner to ubuntu-24.04
- Add explicit SSH setup with sshd start, hard-fail FUSE preflight
- Add pytest --timeout=300, --maxfail=99, JUnit XML, test result artifacts per compiler
2026-05-20 23:28:17 -07:00
h4sh 88692b79eb Merge pull request #352 from abhinavagarwal07/ci-hygiene
ci: harden workflow with SHA pins, permissions, timeouts, and dependabot
2026-05-19 16:48:12 +10:00
Abhinav Agarwal 035dcde9ba ci: harden workflow with SHA pins, permissions, timeouts, and dependabot
- Pin all actions to Node 24-capable full SHAs (checkout v6.0.2, setup-python v6.2.0, upload-artifact v7.0.1)
- Add least-privilege permissions (contents: read) and concurrency cancellation
- Pin python-version to 3.12, pin runner to ubuntu-24.04
- Add pytest --timeout=300, --maxfail=99 (overrides pytest.ini -x), JUnit XML output
- Add explicit SSH setup with sshd start and connectivity preflight
- Hard-fail FUSE preflight (job stops if /dev/fuse or fusermount3 missing)
- Upload test results and meson logs as artifacts
- Add dependabot config for weekly action version updates
2026-05-18 21:04:29 -07:00
h4sh cc14ae1c79 Merge pull request #351 from abhinavagarwal07/fix-conntab-null-deref
fix conntab lookup after rename in sshfs_release
2026-05-18 12:39:06 +10:00
h4sh 78f807b3dd Merge pull request #350 from abhinavagarwal07/fix-statfs-div-by-zero
fix statfs divide-by-zero when blksize is 0
2026-05-18 12:38:39 +10:00
h4sh fd885f023c Merge pull request #349 from abhinavagarwal07/fix-sftp-read-buffer-safety
fix malformed SFTP reply handling
2026-05-18 12:37:56 +10:00
Abhinav Agarwal 033a1d48f5 fix conntab lookup after rename in sshfs_release
sshfs_release looked up the conntab entry by path, but sshfs_rename
moves that entry to the new path. Closing the original handle after a
rename can miss the entry and dereference NULL.

Store the conntab_entry pointer on sshfs_file and use it for release
and open-failure cleanup. Add a regression test for open -> rename -> close.
2026-05-17 18:58:06 -07:00
Abhinav Agarwal 82285f9c28 fix statfs divide-by-zero when blksize is 0
On macOS, sshfs.blksize is set to 0. The statfs fallback path
(used when the server lacks the statvfs extension) divides by
f_frsize, which inherits that zero. Use 4096 as the default.
2026-05-17 18:17:23 -07:00
Abhinav Agarwal 43b2708186 fix malformed SFTP reply handling 2026-05-17 18:12:57 -07:00
11 changed files with 703 additions and 59 deletions
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 14
groups:
github-actions:
patterns:
- "*"
+149
View File
@@ -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/
+114 -22
View File
@@ -7,45 +7,137 @@ on:
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: make ssh into localhost without prompt possible for tests
run: |
ssh-keygen -b 2048 -t rsa -f ~/.ssh/id_rsa -q -N ""
cat ~/.ssh/id_rsa.pub > ~/.ssh/authorized_keys
- name: run tests
- name: Run tests
timeout-minutes: 20
run: |
cd build
python3 -m pytest test/
python3 -m pytest --maxfail=99 --timeout=300 --junitxml=test-results.xml test/
- 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: |
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: Print tool versions
run: |
${{ 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
+4 -2
View File
@@ -1,7 +1,9 @@
Current Maintainer
Current Maintainers
------------------
None.
Haoxi Tan <h4sh5@pm.me>
Abhinav Agarwal <abhinavagarwal1996@gmail.com>
Past Maintainers
+12
View File
@@ -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
View File
@@ -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' ])
+103 -32
View File
@@ -280,6 +280,11 @@ struct read_chunk {
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 @@ struct sshfs_file {
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 @@ struct sshfs {
int fstat_workaround;
int createmode_workaround;
int transform_symlinks;
int contain_symlinks;
int follow_symlinks;
int no_check_root;
int detect_uid;
@@ -492,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),
@@ -1441,23 +1445,24 @@ static int do_read(struct conn *conn, struct buffer *buf)
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;
}
@@ -1530,10 +1535,14 @@ static int process_one_request(struct conn *conn)
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 *)
@@ -2169,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;
@@ -2233,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);
@@ -2763,6 +2809,12 @@ static int sshfs_utimens(const char *path, const struct timespec tv[2],
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)
{
@@ -2823,12 +2875,13 @@ static int sshfs_open_common(const char *path, mode_t mode,
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);
@@ -2868,10 +2921,12 @@ static int sshfs_open_common(const char *path, mode_t mode,
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);
}
@@ -2942,7 +2997,6 @@ static int sshfs_release(const char *path, struct fuse_file_info *fi)
{
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);
@@ -2950,12 +3004,13 @@ static int sshfs_release(const char *path, struct fuse_file_info *fi)
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);
@@ -3382,7 +3437,7 @@ static int sshfs_statfs(const char *path, struct statvfs *buf)
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
@@ -3705,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"
@@ -3939,7 +3997,7 @@ static char *tokenize_on_space(char *str)
start = pos;
while (pos && *pos != '\0') {
while (*pos != '\0') {
// break on space, but not on '\ '
if (*pos == ' ' && *(pos - 1) != '\\') {
break;
@@ -4004,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;
}
@@ -4262,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;
@@ -4312,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) {
@@ -4395,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)
@@ -4406,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);
+18 -1
View File
@@ -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
View File
@@ -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',
+60
View File
@@ -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
+229
View File
@@ -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"]
@@ -168,6 +172,7 @@ def test_sshfs(
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
@@ -304,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)
@@ -503,6 +514,23 @@ def tst_rename_sibling(mnt_dir):
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):
name1 = pjoin(mnt_dir, name_generator())
name2 = pjoin(mnt_dir, name_generator())
@@ -851,3 +879,204 @@ def test_direct_io(tmpdir, capfd):
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