Skip to content

fix(sftp): sftp.root chroot rejects all non-root path resolution (cd/get/open/stat) because resolve_chroot does not re-root absolute virtual paths #214

Description

@Yaminyam

Summary

When sftp.root (SFTP chroot) is configured, directory listing works but every non-root path resolution fails: cd subdir, get subdir/nested.txt, and even get hello.txt (a file at the chroot root) are rejected as Access denied: path outside root or reported not found. This makes the sftp.root confinement feature unusable. Reproduces on macOS (aarch64) and Linux (x86_64-musl) bssh-server v2.2.3.

Root Cause

There is no separate resolution routine for readdir; every path-based SFTP operation funnels through one helper, resolve_chroot (src/server/sftp.rs:218-300). The divergence between "listing works" and "everything else fails" comes down to which argument each op passes and a /-only special case:

  • Root listing works because it resolves the literal path "/", which resolve_chroot special-cases to the root (src/server/sftp.rs:232-234), and readdir then just replays the entries cached at opendir time (src/server/sftp.rs:929-956) without resolving anything.
  • Everything else fails because after connect, realpath strips the root prefix and reports virtual paths rooted at / to the client (src/server/sftp.rs:1089-1096). The client therefore sends absolute virtual paths like /subdir and /hello.txt. In resolve_chroot, the absolute branch (src/server/sftp.rs:228-257) skips the / special case, computes normalize_components("/subdir") = /subdir, then requires normalized == root || normalized.starts_with(root). Since /subdir does not start with the host root (e.g. /tmp/cctest/data), it falls through to Err(permission_denied("Access denied: path outside root")) (src/server/sftp.rs:254-256).

The core inconsistency: realpath emits /… virtual paths (strips the root), but resolve_chroot never re-adds the root to absolute inputs. It demands the client's path already contain the host root prefix, which a chrooted client cannot know. This only works when root == "/" (then starts_with is trivially true). Relative paths would resolve correctly (the relative branch at src/server/sftp.rs:262-299 joins onto root with ..-clamping), but a chrooted client rarely sends relative paths because realpath told it everything lives under /.

For get hello.txt, the client issues stat("/hello.txt") / open("/hello.txt"); the same rejection surfaces in the OpenSSH client as File "/hello.txt" not found. All path handlers (open :605, opendir :834, stat :988, lstat :1036, realpath :1080) call resolve_pathresolve_path_staticresolve_chroot, so all of them are affected.

Proposed Solution

In resolve_chroot's absolute branch (src/server/sftp.rs:228-257), treat an absolute client path as relative to the chroot root (OpenSSH ChrootDirectory re-rooting semantics): strip the leading /, join onto root, and ..-clamp so the result cannot escape root, exactly as the existing relative branch (src/server/sftp.rs:262-299) already does. Keep the /root special case. After the fix, /subdir maps to <root>/subdir, /hello.txt to <root>/hello.txt, and ..-escape is still blocked.

The doc comment on the config field (src/server/config/types.rs:250-262) currently encodes the buggy intent ("Absolute client paths inside root are honored as-is; outside root are rejected") and should be updated to describe re-rooting.

Implementation Notes

  • Fix site: resolve_chroot, src/server/sftp.rs:228-257 (absolute branch). Reuse the relative-branch join/clamp logic at :262-299.
  • Config field + doc: SftpConfig.root at src/server/config/types.rs:250-262; threaded via src/server/config/mod.rs:166,681 and consumed at src/server/handler.rs:1343-1347.
  • readdir needs no change (it replays cached entries); the fix is entirely in path resolution.
  • src/server/filter/path.rs is the transfer allow/deny filter and is not involved in chroot resolution.
  • Tests to update: the existing chroot tests assert the current (buggy) "absolute-inside-root honored verbatim / outside-root rejected" behavior at src/server/sftp.rs:1523-1547 and :1877-1898; they must be rewritten to assert re-rooting. Add coverage for: /hello.txt<root>/hello.txt, /subdir<root>/subdir, /subdir/nested.txt, and a ..-escape attempt (/../etc/passwd) staying clamped inside root.

Acceptance Criteria

  • With sftp.root set, cd subdir, get hello.txt, and get subdir/nested.txt all succeed against the OpenSSH sftp client.
  • Absolute virtual paths from the client are re-rooted under sftp.root rather than rejected.
  • ..-based escape attempts remain blocked (clamped inside root); an out-of-root path cannot be reached.
  • Behavior verified on both macOS (aarch64) and Linux (x86_64-musl) builds.
  • Chroot tests at src/server/sftp.rs:1523-1547 and :1877-1898 updated to assert re-rooting, plus new cases for root-level files and ..-clamping.
  • The SftpConfig.root doc comment (src/server/config/types.rs:250-262) is corrected to describe re-rooting semantics.

Related


Original Suggestion

Title: SFTP sftp.root chroot: readdir works but all path resolution (cd/get/open) fails — even files at the chroot root

Summary

When sftp.root (SFTP chroot) is configured, directory listing works but every path resolution fails — you can ls the root and see entries, but cd <subdir>, get <file>, and even get <file-at-root> all fail. This makes the sftp.root confinement feature unusable.

Reproduces on both macOS (aarch64) and Linux (x86_64-musl) builds of bssh-server v2.2.3.

Reproduction

Config (config.yaml):

server:
  bind_address: 127.0.0.1
  port: 2223
  host_keys: [/tmp/cctest/hostkey]
auth:
  methods: [publickey]
  publickey:
    authorized_keys_pattern: /tmp/cctest/{user}.keys
sftp:
  enabled: true
  root: /tmp/cctest/data        # <-- chroot

Data layout:

/tmp/cctest/data/hello.txt
/tmp/cctest/data/subdir/nested.txt

Run: bssh-server run -c config.yaml -D, then connect with the OpenSSH sftp client:

sftp> pwd
Remote working directory: /
sftp> ls
hello.txt  subdir           # <-- readdir OK
sftp> cd subdir
realpath /subdir: Access denied: path outside root      # <-- BUG
sftp> get subdir/nested.txt
File "/subdir/nested.txt" not found.                    # <-- BUG
sftp> get hello.txt
File "/hello.txt" not found.                            # <-- BUG (file is at chroot root!)

Expected

  • cd subdir → enters /subdir (i.e. <root>/subdir)
  • get hello.txt → downloads <root>/hello.txt
  • get subdir/nested.txt → downloads <root>/subdir/nested.txt

Actual

  • ls (readdir) resolves entries under the chroot root correctly.
  • But realpath/open/stat of any path — including a file directly at the chroot root — is rejected as path outside root or not found.

So there is an inconsistency between the readdir path handling and the open/realpath path handling when sftp.root is set: listing translates the chroot correctly, but per-path resolution does not (it appears to treat the virtual /… as an absolute host path and then reject it against the root boundary).

Workaround

Leave sftp.root unset (root: null) — without chroot, all SFTP operations work correctly (verified: nav, upload, download, rename, chmod, rm/rmdir, spaces/unicode filenames, multi-MB binary transfer with matching checksum).

Environment

  • bssh-server 2.2.3 (bssh-server-macos-aarch64 and bssh-server-linux-x86_64-musl)
  • OpenSSH sftp client

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions