Skip to content

Commit ae624be

Browse files
authored
fix: SCP/SFTP path resolution and dead chroot config (#186) (#194)
* feat: add scp.root config and thread sftp/scp chroot through ServerConfig 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 * fix: stop SCP/SFTP path doubling and honor target_is_directory 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 * docs: cover new chroot semantics and add issue #186 integration tests 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 * fix(security): block chroot bypass via parent-directory symlinks 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. * fix: remove dead starts_with check after ParentDir clamping in chroot 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.
1 parent dd6e7e3 commit ae624be

10 files changed

Lines changed: 1590 additions & 342 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- Internal fork of `russh-sftp` as `crates/bssh-russh-sftp` with a `serde_bytes` performance fix for `SSH_FXP_WRITE` and `SSH_FXP_DATA` packets. The upstream serde derive routes `Vec<u8>` through `deserialize_seq` (byte-by-byte), accounting for ~42% of server CPU during 1 GiB SFTP uploads in `perf` profiling. Annotating the `data` fields with `#[serde(with = "serde_bytes")]` and implementing wire-compatible `serialize_bytes` on the SFTP `Serializer` routes through the existing bulk `deserialize_byte_buf`/`try_get_bytes` path. Measured impact on a CPU-bound host (Xeon Silver 4214): 1 GiB SFTP upload throughput improves from 74.8 MiB/s to 96.4 MiB/s (+29%), closing the gap to OpenSSH `sftp-server` from ~26% to ~5%.
12+
- `scp.root` configuration field. SCP transfers now honor a chroot setting separate from SFTP. When unset, SCP falls back to `sftp.root`, so a single top-level chroot setting governs both subsystems unless an admin explicitly wants them split.
1213

1314
### Changed
1415
- Switched the top-level `russh-sftp` dependency from crates.io `russh-sftp = "2.1.1"` to `russh-sftp = { package = "bssh-russh-sftp", version = "2.1.1", path = "crates/bssh-russh-sftp" }`. All existing `use russh_sftp::...` imports continue to work unchanged.
16+
- **Default file-transfer behavior is no longer chrooted to the user's home directory.** With `sftp.root`/`scp.root` unset (the default), absolute client paths are honored verbatim and relative paths resolve from the user's home directory, matching OpenSSH `sftp-server`/`scp` defaults. Deployments that intentionally want chroot-at-home-dir must now set `sftp.root: <home dir>` (or equivalent) explicitly. (#186)
17+
18+
### Fixed
19+
- **bssh-server SCP/SFTP path doubling on absolute client paths** (#186). `ScpHandler::resolve_path` and `SftpHandler::resolve_path_static` previously re-rooted every absolute client path under the user's home directory, so `scp local user@host:/home/work/file.bin` wrote to `/home/work/home/work/file.bin` and `bssh upload local /abs/remote.bin` failed with `No such file`. The resolver now treats absolute client paths verbatim when no chroot is configured and rejects out-of-chroot absolute paths with `permission_denied` when one is. Path-traversal and symlink-escape protections continue to apply.
20+
- **SCP single-file destinations no longer have the source filename appended** (#186). `ScpHandler::receive_file` now consults `target_is_directory` (parsed from `-d`/`-r`) and the filesystem state of the resolved target. `scp local.bin user@host:/tmp/dest.bin` now writes to `/tmp/dest.bin` instead of `/tmp/dest.bin/local.bin`. Directory destinations (`/tmp/dir/`, existing directory, or `-d`/`-r` flag) keep the previous filename-appending behavior.
21+
- **Configured `sftp.root` is no longer dead code** (#186). The handler-construction sites in `SshHandler` previously hard-coded `user_info.home_dir` as the chroot root and ignored `config.sftp.root` entirely. Setting `sftp.root` in the YAML configuration now actually changes the SFTP chroot. The same plumbing now exists for `scp.root`.
22+
- **Chroot bypass via intermediate-directory symlink**. 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, blocking the parent-symlink escape. Found during PR #194 review.
1523

1624
## [2.1.2] - 2026-04-27
1725

docs/architecture/server-configuration.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,20 @@ shell:
140140
# SFTP subsystem configuration
141141
sftp:
142142
enabled: true # Default: true
143-
# Optional chroot directory
143+
# Optional chroot directory.
144+
# When unset (default), no chroot: absolute client paths are honored
145+
# verbatim and relative paths resolve from the user's home directory.
146+
# When set, clients are confined to this directory; absolute paths
147+
# outside it are rejected with permission_denied.
144148
root: /data/sftp
145149

146150
# SCP protocol configuration
147151
scp:
148152
enabled: true # Default: true
153+
# Optional chroot directory. Same semantics as sftp.root. When unset,
154+
# falls back to sftp.root, so configure scp.root only when SCP and SFTP
155+
# need separate chroots.
156+
root: /data/scp
149157

150158
# File transfer filtering
151159
filter:

docs/man/bssh-server.8

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.\" Manpage for bssh-server
22
.\" Contact the maintainers to correct errors or typos.
3-
.TH BSSH-SERVER 8 "April 2026" "v2.1.2" "System Administration Commands"
3+
.TH BSSH-SERVER 8 "April 2026" "v2.1.3" "System Administration Commands"
44

55
.SH NAME
66
bssh-server \- Backend.AI SSH Server for container environments
@@ -173,10 +173,15 @@ Authentication methods and settings (methods, publickey, password)
173173
Shell execution settings (default, command_timeout, env)
174174
.TP
175175
.B sftp
176-
SFTP subsystem settings (enabled, root)
176+
SFTP subsystem settings (\fBenabled\fR, \fBroot\fR). \fBroot\fR sets a chroot
177+
directory that confines SFTP transfers. When unset (default), absolute client
178+
paths are honored verbatim and relative paths resolve from the user's home
179+
directory, matching OpenSSH \fBsftp-server\fR behavior.
177180
.TP
178181
.B scp
179-
SCP protocol settings (enabled)
182+
SCP protocol settings (\fBenabled\fR, \fBroot\fR). \fBroot\fR has the same
183+
semantics as \fBsftp.root\fR. When \fBscp.root\fR is unset, it falls back to
184+
\fBsftp.root\fR so a single setting governs both subsystems.
180185
.TP
181186
.B filter
182187
File transfer filtering (enabled, rules)
@@ -271,6 +276,11 @@ Enable IP allowlists in production to restrict access to trusted networks
271276
Configure rate limiting to prevent brute-force attacks
272277
.IP \(bu 2
273278
Enable audit logging for security monitoring and compliance
279+
.IP \(bu 2
280+
When \fBsftp.root\fR or \fBscp.root\fR is set, the chroot resolver
281+
canonicalizes the closest existing ancestor of the requested path and verifies
282+
it stays inside the configured root. This blocks attacks that route writes
283+
outside the chroot through intermediate-directory symlinks inside it.
274284

275285
.SH EXIT STATUS
276286
.TP

docs/security.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,39 @@ sftp:
229229
scp:
230230
enabled: false
231231
232-
# Or enable with restrictions
232+
# Or enable with chroot. SCP falls back to sftp.root when scp.root is unset,
233+
# so a single setting governs both subsystems.
233234
sftp:
234235
enabled: true
235-
root: /data/sftp # Chroot to this directory
236+
root: /data/sftp # Confine SFTP and SCP to this directory.
237+
238+
# Use scp.root only when SCP and SFTP need separate chroots:
239+
scp:
240+
enabled: true
241+
root: /data/scp
236242
```
237243

244+
**Chroot semantics.** When `root` is set:
245+
246+
- Absolute client paths inside `root` are honored as-is. No path doubling.
247+
- Absolute client paths outside `root` are rejected with `permission_denied`.
248+
- Relative client paths resolve under `root`, with `..` clamped at the
249+
chroot boundary.
250+
- The pseudo-root `/` (returned by `realpath`) maps back to the chroot
251+
directory so interactive SFTP clients (`cd /`, `pwd`) still work.
252+
- Path-traversal and symlink-escape protections continue to apply,
253+
including for paths whose final component does not exist yet: the closest
254+
existing ancestor is canonicalized and verified to stay inside `root`.
255+
This blocks intermediate-directory symlinks pointing outside the chroot.
256+
257+
When `root` is unset (default since v2.1.3, per #186), the handler runs
258+
without chroot. Absolute paths are honored verbatim and relative paths
259+
resolve from the user's home directory, matching OpenSSH `sftp-server`.
260+
This is the recommended default for Backend.AI session containers and any
261+
deployment whose clients submit absolute filesystem paths (such as the
262+
WebUI's "Download SSH key / SCP example" snippet, or `bssh upload
263+
/abs/remote.bin`).
264+
238265
### File Transfer Filtering
239266

240267
Block dangerous file types:

src/server/config/mod.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,25 @@ pub struct ServerConfig {
154154
#[serde(default = "default_true")]
155155
pub scp_enabled: bool,
156156

157+
/// Optional chroot directory for SFTP operations.
158+
///
159+
/// When `None` (default), SFTP runs without chroot: absolute client paths
160+
/// are used verbatim and relative paths resolve from the user's home
161+
/// directory, matching OpenSSH `sftp-server` semantics.
162+
///
163+
/// When set, SFTP clients are confined to this directory; absolute paths
164+
/// outside it are rejected with `permission_denied`.
165+
#[serde(default)]
166+
pub sftp_root: Option<PathBuf>,
167+
168+
/// Optional chroot directory for SCP transfers.
169+
///
170+
/// Has the same semantics as [`Self::sftp_root`]. When `None` and
171+
/// `sftp_root` is set, SCP falls back to `sftp_root`. Configure both
172+
/// fields only if SCP and SFTP need different chroots.
173+
#[serde(default)]
174+
pub scp_root: Option<PathBuf>,
175+
157176
/// Time window for counting authentication attempts in seconds.
158177
///
159178
/// Default: 300 (5 minutes)
@@ -293,6 +312,8 @@ impl Default for ServerConfig {
293312
password_auth: PasswordAuthConfigSerde::default(),
294313
exec: ExecConfig::default(),
295314
scp_enabled: true,
315+
sftp_root: None,
316+
scp_root: None,
296317
auth_window_secs: default_auth_window_secs(),
297318
ban_time_secs: default_ban_time_secs(),
298319
whitelist_ips: Vec::new(),
@@ -564,6 +585,25 @@ impl ServerConfigBuilder {
564585
self
565586
}
566587

588+
/// Set the SFTP chroot directory.
589+
///
590+
/// When `None`, SFTP runs without chroot (OpenSSH-compatible default).
591+
/// When set, SFTP clients are confined to this directory.
592+
pub fn sftp_root(mut self, root: Option<PathBuf>) -> Self {
593+
self.config.sftp_root = root;
594+
self
595+
}
596+
597+
/// Set the SCP chroot directory.
598+
///
599+
/// When `None`, SCP falls back to [`sftp_root`](Self::sftp_root) if set,
600+
/// otherwise runs without chroot. Set both fields only when SCP and SFTP
601+
/// need different chroots.
602+
pub fn scp_root(mut self, root: Option<PathBuf>) -> Self {
603+
self.config.scp_root = root;
604+
self
605+
}
606+
567607
/// Set the maximum sessions per user.
568608
pub fn max_sessions_per_user(mut self, max: usize) -> Self {
569609
self.config.max_sessions_per_user = max;
@@ -635,6 +675,10 @@ impl ServerFileConfig {
635675
blocked_commands: Vec::new(),
636676
},
637677
scp_enabled: self.scp.enabled,
678+
// SCP falls back to sftp.root when scp.root is unset so a single
679+
// top-level chroot setting governs both subsystems.
680+
scp_root: self.scp.root.or_else(|| self.sftp.root.clone()),
681+
sftp_root: self.sftp.root,
638682
auth_window_secs: self.security.auth_window,
639683
ban_time_secs: self.security.ban_time,
640684
whitelist_ips: self.security.whitelist_ips,
@@ -701,6 +745,49 @@ mod tests {
701745
assert!(server_config.allow_password_auth);
702746
}
703747

748+
#[test]
749+
fn sftp_root_threads_into_server_config() {
750+
// Setting sftp.root should propagate to ServerConfig.sftp_root, and
751+
// scp_root should fall back to it when scp.root is unset.
752+
let mut file_config = ServerFileConfig::default();
753+
file_config.server.host_keys = vec![PathBuf::from("/test/key")];
754+
file_config.sftp.root = Some(PathBuf::from("/srv/sftp"));
755+
756+
let server_config = file_config.into_server_config();
757+
758+
assert_eq!(server_config.sftp_root, Some(PathBuf::from("/srv/sftp")));
759+
assert_eq!(server_config.scp_root, Some(PathBuf::from("/srv/sftp")));
760+
}
761+
762+
#[test]
763+
fn scp_root_overrides_sftp_root_fallback() {
764+
// When scp.root is explicitly set, it takes precedence over the
765+
// sftp.root fallback so admins can split the two chroots.
766+
let mut file_config = ServerFileConfig::default();
767+
file_config.server.host_keys = vec![PathBuf::from("/test/key")];
768+
file_config.sftp.root = Some(PathBuf::from("/srv/sftp"));
769+
file_config.scp.root = Some(PathBuf::from("/srv/scp"));
770+
771+
let server_config = file_config.into_server_config();
772+
773+
assert_eq!(server_config.sftp_root, Some(PathBuf::from("/srv/sftp")));
774+
assert_eq!(server_config.scp_root, Some(PathBuf::from("/srv/scp")));
775+
}
776+
777+
#[test]
778+
fn no_chroot_by_default() {
779+
// The new default: both scp_root and sftp_root are None when no
780+
// configuration is provided. This is the OpenSSH-compatible default
781+
// documented for issue #186.
782+
let mut file_config = ServerFileConfig::default();
783+
file_config.server.host_keys = vec![PathBuf::from("/test/key")];
784+
785+
let server_config = file_config.into_server_config();
786+
787+
assert!(server_config.sftp_root.is_none());
788+
assert!(server_config.scp_root.is_none());
789+
}
790+
704791
#[test]
705792
fn test_config_new() {
706793
let config = ServerConfig::new();

src/server/config/types.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,18 @@ pub struct SftpConfig {
247247
#[serde(default = "default_true")]
248248
pub enabled: bool,
249249

250-
/// Root directory for SFTP operations.
251-
///
252-
/// If set, SFTP clients will be chrooted to this directory.
253-
/// If None, users have access to the entire filesystem (subject to permissions).
250+
/// Optional chroot directory for SFTP operations.
251+
///
252+
/// When set, SFTP clients are confined to this directory:
253+
/// - Absolute client paths inside `root` are honored as-is.
254+
/// - Absolute client paths outside `root` are rejected with `permission_denied`.
255+
/// - Relative client paths resolve under `root`.
256+
/// - `..` traversal is clamped to `root`.
257+
///
258+
/// When `None` (default), no chroot is applied. This matches OpenSSH
259+
/// `sftp-server` behavior: absolute paths are used verbatim and relative
260+
/// paths resolve from the user's home directory. Filesystem permissions
261+
/// remain the only access control.
254262
pub root: Option<PathBuf>,
255263
}
256264

@@ -263,6 +271,14 @@ pub struct ScpConfig {
263271
/// Default: true
264272
#[serde(default = "default_true")]
265273
pub enabled: bool,
274+
275+
/// Optional chroot directory for SCP transfers.
276+
///
277+
/// Has the same semantics as [`SftpConfig::root`]. When `None`, SCP uses
278+
/// `sftp.root` as a fallback so a single `root` setting controls both
279+
/// subsystems. Set this explicitly only when SCP and SFTP need different
280+
/// chroots.
281+
pub root: Option<PathBuf>,
266282
}
267283

268284
/// File transfer filtering configuration.
@@ -650,6 +666,7 @@ impl Default for ScpConfig {
650666
fn default() -> Self {
651667
Self {
652668
enabled: default_true(),
669+
root: None,
653670
}
654671
}
655672
}

src/server/handler.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,11 +1001,14 @@ impl russh::server::Handler for SshHandler {
10011001

10021002
let handle_clone = handle.clone();
10031003

1004-
// Create SCP handler with user's home directory as root
1004+
// Honor the configured chroot. SCP falls back to sftp_root
1005+
// (already merged in into_server_config). When None, no
1006+
// chroot is applied, matching OpenSSH semantics.
10051007
let scp_handler = ScpHandler::from_command(
10061008
&scp_cmd,
10071009
user_info.clone(),
1008-
Some(user_info.home_dir.clone()),
1010+
self.config.scp_root.clone(),
1011+
user_info.home_dir.clone(),
10091012
);
10101013

10111014
// Run SCP in a spawned task so the session loop can process incoming data
@@ -1335,8 +1338,13 @@ impl russh::server::Handler for SshHandler {
13351338
"Starting SFTP session"
13361339
);
13371340

1338-
// Create SFTP handler with user's home directory as root
1339-
let sftp_handler = SftpHandler::new(user_info.clone(), Some(user_info.home_dir));
1341+
// Honor the configured chroot. When `sftp_root` is None,
1342+
// run without chroot, matching OpenSSH `sftp-server` defaults.
1343+
let sftp_handler = SftpHandler::new(
1344+
user_info.clone(),
1345+
self.config.sftp_root.clone(),
1346+
user_info.home_dir,
1347+
);
13401348

13411349
// Run SFTP server on the channel stream
13421350
russh_sftp::server::run(channel.into_stream(), sftp_handler).await;

0 commit comments

Comments
 (0)