fix: SCP/SFTP path resolution and dead chroot config (#186)#194
fix: SCP/SFTP path resolution and dead chroot config (#186)#194
Conversation
…nfig Previously `SftpConfig.root` was declared but never read, and `ScpConfig` had no `root` field at all. Both handler-construction sites in `SshHandler` hard-coded `user_info.home_dir` as the chroot root, so the configured chroot setting was dead code. This commit adds `ScpConfig.root: Option<PathBuf>`, plus `sftp_root` and `scp_root` on the builder-based `ServerConfig`, and threads them through `into_server_config()`. SCP falls back to `sftp.root` when `scp.root` is unset, so a single top-level chroot setting governs both subsystems unless admins explicitly want them split. Also adds focused unit tests covering the precedence rules and the no-chroot default. The handler-side wiring lands in a follow-up commit. Refs #186
Three related defects in bssh-server's file-transfer subsystems caused SCP and SFTP uploads to fail when the client supplied an absolute destination path. Reported in #186 against the published v2.1.1 build. 1. Path doubling. `ScpHandler::resolve_path` and `SftpHandler::resolve_path_static` unconditionally re-rooted every absolute client path under the user's home directory, so `scp local user@host:/home/work/file.bin` against root `/home/work` produced `/home/work/home/work/file.bin`. The same bug broke `bssh upload local /abs/remote.bin` with `No such file`. Both resolvers now carry an `Option<PathBuf>` chroot root and a separate `cwd` for relative paths. With no chroot (the new default), absolute paths are honored verbatim and relative paths resolve from the user's home directory, matching OpenSSH `sftp-server`/`scp`. With chroot, absolute paths inside the chroot are honored verbatim (no doubling); absolute paths outside are rejected with `permission_denied`. Plain `/` keeps mapping to the chroot directory so the `realpath` roundtrip used by interactive SFTP clients still works. Path-traversal via `..` is still clamped to the chroot, and the existing symlink-escape canonicalization is preserved. 2. SCP filename appending. `receive_file` always appended the source filename to the resolved target, so `scp local.bin host:/tmp/dest.bin` wrote to `/tmp/dest.bin/local.bin`. The handler now consults `target_is_directory` (parsed from `-d`/`-r`) and the filesystem state of the resolved target. Single-file destinations write directly to the resolved path; directory destinations (existing directory, `-d`/`-r` flag, or recursive descent) keep the filename-appending behavior. 3. Dead-code chroot config. `SshHandler` now reads `config.sftp_root` and `config.scp_root` and threads them into the handlers, so setting `sftp.root` in the YAML actually changes the chroot. Behavior change: with no `sftp.root`/`scp.root` configured, the server no longer applies an implicit chroot at the user's home directory. Deployments that intentionally wanted that confinement must now set the field explicitly. This matches OpenSSH defaults and is the recommended setup for Backend.AI session containers per the issue. Closes #186
Document the no-chroot default, the chroot semantics (no path doubling inside, rejection outside, `..` clamped at the boundary), and the `scp.root` field that falls back to `sftp.root`. Update the security guide and the server-configuration architecture doc accordingly, and add the migration note to the changelog. Add `tests/scp_sftp_path_resolution_test.rs` covering the Backend.AI reproduction scenarios (absolute SCP/SFTP paths, no doubling, no chroot honors absolute paths, chroot rejects out-of-root paths, `..` clamped, chroot `/` round-trips through `realpath`) plus symlink-escape blocking under chroot. End-to-end tests against a running `bssh-server` with real `scp`/`bssh upload` clients are out of scope for the Rust harness but the path-resolution layer where the bugs lived is covered here. Refs #186
The chroot resolver only verified lexical containment for paths whose final component did not exist (the typical create/mkdir flow). A symlink inside the chroot pointing to a directory outside the chroot let a client target chroot/escape/newfile and have OpenOptions::open() or create_dir() follow the symlink to operate outside the chroot. ScpHandler::resolve_path and SftpHandler::resolve_path_static now walk up to the closest existing ancestor of the target path, canonicalize both that ancestor and the chroot root, and verify the canonical ancestor stays inside the canonical root. Operator-misconfigured chroots that don't exist on disk fall back to the lexical check. Found during PR #194 review. Adds 6 regression tests covering both SCP and SFTP, absolute and relative client paths, file create and mkdir, plus two sanity checks for legitimate nested operations.
PR security & performance reviewSummary: Found and fixed one CRITICAL chroot bypass during review. Path-doubling fix and filename-appending fix verified clean. Performance is fine. CRITICAL fixed in 9903dd8Chroot bypass via intermediate-directory symlinks. Both Reproduced with the unmodified PR branch: let escape_link = chroot.join("escape");
std::os::unix::fs::symlink(&outside_dir, &escape_link).unwrap();
let resolved = handler.resolve_path(&chroot.join("escape").join("newfile.txt"))?;
// Lexically inside the chroot — passed.
OpenOptions::new().write(true).create(true).truncate(true)
.open(&resolved)?
.write_all(b"escaped!")?;
// File written to outside_dir/newfile.txtFix: when the target path does not exist, walk up to the closest existing ancestor, canonicalize both that ancestor and the chroot root, and verify The existing canonicalization on existing paths is preserved unchanged. The fix adds an Six regression tests added in
Verified clean
Performance passNo HIGH issues. Out of scope (informational)
|
… resolvers In both ScpHandler and SftpHandler, the relative-path chroot resolver initializes `resolved` to `root` and only extends it via Normal pushes. The `ParentDir` arm already guards against popping past `root` with `if resolved != root`, making the subsequent `if !resolved.starts_with(root)` check unreachable dead code. Remove it and document why the single guard is sufficient. Also update docs/man/bssh-server.8: document sftp.root and scp.root in the configuration sections, add the intermediate-directory-symlink chroot protection to SECURITY CONSIDERATIONS, and bump the version to v2.1.3 to reflect the PR's unreleased changes.
PR Finalization CompleteTests
Documentation
Code quality
LOW finding (dead code)Removed the dead All checks passing. Ready for merge. |
Summary
Fixes the three defects in `bssh-server`'s file-transfer subsystems that
made SCP and SFTP uploads with absolute destination paths fail, blocking
adoption of `bssh-server` as a drop-in OpenSSH replacement in Backend.AI
session containers. A chroot-bypass security vulnerability found during
review is also fixed.
`SftpHandler::resolve_path_static` unconditionally re-rooted every
absolute client path under the user's home directory, so
`scp local user@host:/home/work/file.bin` produced
`/home/work/home/work/file.bin`. The same bug caused `bssh upload`
to fail with `No such file`. Both resolvers now carry an
`Option` chroot root and a separate `cwd` for relative
paths. With no chroot (the new default), absolute paths are honored
verbatim and relative paths resolve from the user's home directory,
matching OpenSSH `sftp-server`/`scp`. With chroot, absolute paths
inside the chroot are honored verbatim (no doubling); absolute paths
outside are rejected with `permission_denied`. Plain `/` keeps
mapping to the chroot directory so the `realpath` round-trip used
by interactive SFTP clients still works.
filename to the resolved target, so
`scp local.bin host:/tmp/dest.bin` wrote to `/tmp/dest.bin/local.bin`.
The handler now consults `target_is_directory` (parsed from `-d`/`-r`)
and the filesystem state of the resolved target. Single-file
destinations write directly to the resolved path; directory
destinations (existing directory, `-d`/`-r` flag, or recursive
descent) keep the filename-appending behavior.
`user_info.home_dir` as the chroot root and ignored
`config.sftp.root`. Both handler-construction sites now read
`config.sftp_root`/`config.scp_root` and thread them through.
The chroot resolver previously checked only lexical containment for
paths whose final component did not exist (typical for new-file
creates and `mkdir`). A symlink inside the chroot pointing to a
directory outside the chroot would let a client target
`chroot/escape/newfile` and have `open(...)`/`create_dir(...)` follow
the symlink, writing outside the chroot. Both `ScpHandler::resolve_path`
and `SftpHandler::resolve_path_static` now canonicalize the closest
existing ancestor of the target path and verify it stays inside the
canonicalized chroot root, blocking parent-symlink escapes.
Default behavior change (migration note)
With `sftp.root`/`scp.root` unset (the default), the server no longer
applies an implicit chroot at the user's home directory. Deployments
that intentionally wanted that confinement must now set the field
explicitly, e.g.
```yaml
sftp:
root: /home/work # or whatever the desired chroot is
```
This matches OpenSSH defaults and is the recommended setup for
Backend.AI session containers per the issue. The `scp.root` field is
new and falls back to `sftp.root` when unset, so a single top-level
chroot setting governs both subsystems unless an admin explicitly
splits them.
Design choice: `scp.root` falls back to `sftp.root`
The issue suggested either adding `scp.root` or sharing `sftp.root`. I
went with both: `ScpConfig.root: Option` exists and, when
unset, the loader falls back to `sftp.root` in `into_server_config()`.
This keeps the common case (one top-level chroot) ergonomic while
letting deployments split SCP and SFTP chroots when they need to.
Acceptance criteria
writes to exactly `/abs/path/file` when no chroot is configured.
`/abs/dir/file` when the target is an existing directory.
file` errors against a server with no chroot.
chroot are rejected and requests inside succeed without path doubling.
intermediate-directory-symlink security fix.
Test plan
out-of-scope warnings in vendored `bssh-russh-sftp` and
`ssh_config/path.rs` unchanged).
next pre-release build.
Closes #186