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_path → resolve_path_static → resolve_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
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
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 evenget hello.txt(a file at the chroot root) are rejected asAccess denied: path outside rootor reportednot found. This makes thesftp.rootconfinement feature unusable. Reproduces on macOS (aarch64) and Linux (x86_64-musl)bssh-serverv2.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:"/", whichresolve_chrootspecial-cases to the root (src/server/sftp.rs:232-234), andreaddirthen just replays the entries cached atopendirtime (src/server/sftp.rs:929-956) without resolving anything.realpathstrips 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/subdirand/hello.txt. Inresolve_chroot, the absolute branch (src/server/sftp.rs:228-257) skips the/special case, computesnormalize_components("/subdir") = /subdir, then requiresnormalized == root || normalized.starts_with(root). Since/subdirdoes not start with the host root (e.g./tmp/cctest/data), it falls through toErr(permission_denied("Access denied: path outside root"))(src/server/sftp.rs:254-256).The core inconsistency:
realpathemits/…virtual paths (strips the root), butresolve_chrootnever 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 whenroot == "/"(thenstarts_withis trivially true). Relative paths would resolve correctly (the relative branch atsrc/server/sftp.rs:262-299joins onto root with..-clamping), but a chrooted client rarely sends relative paths becauserealpathtold it everything lives under/.For
get hello.txt, the client issuesstat("/hello.txt")/open("/hello.txt"); the same rejection surfaces in the OpenSSH client asFile "/hello.txt" not found. All path handlers (open:605,opendir:834,stat:988,lstat:1036,realpath:1080) callresolve_path→resolve_path_static→resolve_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 (OpenSSHChrootDirectoryre-rooting semantics): strip the leading/, join ontoroot, and..-clamp so the result cannot escaperoot, exactly as the existing relative branch (src/server/sftp.rs:262-299) already does. Keep the/→rootspecial case. After the fix,/subdirmaps to<root>/subdir,/hello.txtto<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 insiderootare honored as-is; outsiderootare rejected") and should be updated to describe re-rooting.Implementation Notes
resolve_chroot,src/server/sftp.rs:228-257(absolute branch). Reuse the relative-branch join/clamp logic at:262-299.SftpConfig.rootatsrc/server/config/types.rs:250-262; threaded viasrc/server/config/mod.rs:166,681and consumed atsrc/server/handler.rs:1343-1347.readdirneeds no change (it replays cached entries); the fix is entirely in path resolution.src/server/filter/path.rsis the transfer allow/deny filter and is not involved in chroot resolution.src/server/sftp.rs:1523-1547and: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
sftp.rootset,cd subdir,get hello.txt, andget subdir/nested.txtall succeed against the OpenSSHsftpclient.sftp.rootrather than rejected...-based escape attempts remain blocked (clamped inside root); an out-of-root path cannot be reached.src/server/sftp.rs:1523-1547and:1877-1898updated to assert re-rooting, plus new cases for root-level files and..-clamping.SftpConfig.rootdoc comment (src/server/config/types.rs:250-262) is corrected to describe re-rooting semantics.Related
Original Suggestion
Title: SFTP
sftp.rootchroot: readdir works but all path resolution (cd/get/open) fails — even files at the chroot rootSummary
When
sftp.root(SFTP chroot) is configured, directory listing works but every path resolution fails — you canlsthe root and see entries, butcd <subdir>,get <file>, and evenget <file-at-root>all fail. This makes thesftp.rootconfinement feature unusable.Reproduces on both macOS (aarch64) and Linux (x86_64-musl) builds of
bssh-serverv2.2.3.Reproduction
Config (
config.yaml):Data layout:
Run:
bssh-server run -c config.yaml -D, then connect with the OpenSSHsftpclient:Expected
cd subdir→ enters/subdir(i.e.<root>/subdir)get hello.txt→ downloads<root>/hello.txtget subdir/nested.txt→ downloads<root>/subdir/nested.txtActual
ls(readdir) resolves entries under the chroot root correctly.realpath/open/statof any path — including a file directly at the chroot root — is rejected aspath outside rootornot found.So there is an inconsistency between the readdir path handling and the open/realpath path handling when
sftp.rootis 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.rootunset (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-server2.2.3 (bssh-server-macos-aarch64andbssh-server-linux-x86_64-musl)sftpclient