Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **Make `sftp.root`/`scp.root` chroot usable by re-anchoring client paths under the chroot root** (#214). With a chroot configured, directory listing worked but every path resolution failed: `cd subdir`, `get file`, and even `get` of a file sitting directly at the chroot root were rejected as `path outside root` or `not found`, so only bare `/` and `readdir` functioned. Under chroot the client's coordinate space is rooted at `/` (this is what `realpath` reports and what the client sends back), but `resolve_chroot` treated a client absolute path such as `/subdir` as a *host* path and rejected anything not starting with the host root, so `/subdir` (meaning `<root>/subdir`) never matched. A prior change had introduced this to avoid "path doubling", but real clients never send the host path; they send chroot-relative absolute paths, so the guard broke the normal case. The SFTP and SCP resolvers now both interpret absolute and relative client paths relative to `root` (OpenSSH `ChrootDirectory` re-rooting), ignoring a leading `/`, dropping `.`, and clamping `..` so traversal still cannot escape. Containment is verified: `..` stays pinned at the root and a client `/etc/passwd` maps to `<root>/etc/passwd` inside the jail, never the host file. SCP previously kept the old "reject absolute outside root" behavior, so it is unified here to match `sftp.root`. Verified end-to-end with the OpenSSH `sftp` client (`cd`/`get`/root-level files/Unicode names all work; escape attempts stay confined) and covered by updated unit and integration tests.

### Security
- **Confine absolute SFTP symlink targets to the chroot** (#214). Once `resolve_chroot` stopped rejecting out-of-root absolute paths, the SFTP `symlink` handler's containment guard became a no-op, so a chrooted client could create a link whose on-disk target was an absolute *host* path (for example `symlink /link /etc/passwd`). Because bssh uses a virtual chroot with no `chroot(2)`, that link resolved to the real host filesystem. Absolute symlink targets are now re-anchored under the chroot before the link is written, so a created link can never point outside the jail; relative targets keep OpenSSH-compatible verbatim storage.

## [2.2.3] - 2026-05-25

### Security
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,10 @@ sftp:
# Optional chroot directory.
# When unset (default), no chroot: absolute client paths are honored
# verbatim and relative paths resolve from the user's home directory.
# When set, clients are confined to this directory; absolute paths
# outside it are rejected with permission_denied.
# When set, clients are confined to this directory; the client's / is the
# chroot root, so absolute and relative paths alike are re-anchored under it
# (a host-looking /etc/passwd is confined to <root>/etc/passwd) and .. is
# clamped to the chroot.
root: /data/sftp

# SCP protocol configuration
Expand Down
10 changes: 7 additions & 3 deletions docs/man/bssh-server.8
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,13 @@ Shell execution settings (default, command_timeout, env)
.TP
.B sftp
SFTP subsystem settings (\fBenabled\fR, \fBroot\fR). \fBroot\fR sets a chroot
directory that confines SFTP transfers. When unset (default), absolute client
paths are honored verbatim and relative paths resolve from the user's home
directory, matching OpenSSH \fBsftp-server\fR behavior.
directory that confines SFTP transfers. When set, the client's \fB/\fR is the
chroot root: absolute and relative client paths are both re-anchored under it
(OpenSSH \fBChrootDirectory\fR semantics), so a host-looking \fB/etc/passwd\fR
is confined to \fB<root>/etc/passwd\fR and \fB..\fR cannot escape. When unset
(default), absolute client paths are honored verbatim and relative paths
resolve from the user's home directory, matching OpenSSH \fBsftp-server\fR
behavior.
.TP
.B scp
SCP protocol settings (\fBenabled\fR, \fBroot\fR). \fBroot\fR has the same
Expand Down
28 changes: 16 additions & 12 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,18 +241,22 @@ scp:
root: /data/scp
```

**Chroot semantics.** When `root` is set:

- Absolute client paths inside `root` are honored as-is. No path doubling.
- Absolute client paths outside `root` are rejected with `permission_denied`.
- Relative client paths resolve under `root`, with `..` clamped at the
chroot boundary.
- The pseudo-root `/` (returned by `realpath`) maps back to the chroot
directory so interactive SFTP clients (`cd /`, `pwd`) still work.
- Path-traversal and symlink-escape protections continue to apply,
including for paths whose final component does not exist yet: the closest
existing ancestor is canonicalized and verified to stay inside `root`.
This blocks intermediate-directory symlinks pointing outside the chroot.
**Chroot semantics.** When `root` is set, the client's `/` is the chroot root
(OpenSSH `ChrootDirectory` re-rooting semantics). SFTP and SCP behave the same
way:

- Both absolute and relative client paths are re-anchored under `root`, so
`/subdir` resolves to `<root>/subdir` and plain `/` maps to the chroot
directory (interactive clients' `cd /`, `pwd` keep working).
- A host-looking path such as `/etc/passwd` is confined to `<root>/etc/passwd`,
never the host file.
- `..` traversal is clamped at the chroot boundary and can never escape.
- Path-traversal and symlink-escape protections apply, including for paths
whose final component does not exist yet: the closest existing ancestor is
canonicalized and verified to stay inside `root`, which blocks
intermediate-directory symlinks pointing outside the chroot. An absolute
SFTP symlink target is itself re-anchored under `root`, so a created link can
never point at the host filesystem.

When `root` is unset (default since v2.1.3, per #186), the handler runs
without chroot. Absolute paths are honored verbatim and relative paths
Expand Down
6 changes: 4 additions & 2 deletions src/server/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,10 @@ pub struct ServerConfig {
/// are used verbatim and relative paths resolve from the user's home
/// directory, matching OpenSSH `sftp-server` semantics.
///
/// When set, SFTP clients are confined to this directory; absolute paths
/// outside it are rejected with `permission_denied`.
/// When set, SFTP clients are confined to this directory. The client's `/`
/// is the chroot root, so absolute and relative paths are re-anchored under
/// it (a host-looking `/etc/passwd` is confined to `<root>/etc/passwd`) and
/// `..` cannot escape.
#[serde(default)]
pub sftp_root: Option<PathBuf>,

Expand Down
14 changes: 9 additions & 5 deletions src/server/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,15 @@ pub struct SftpConfig {

/// Optional chroot directory for SFTP operations.
///
/// When set, SFTP clients are confined to this directory:
/// - Absolute client paths inside `root` are honored as-is.
/// - Absolute client paths outside `root` are rejected with `permission_denied`.
/// - Relative client paths resolve under `root`.
/// - `..` traversal is clamped to `root`.
/// When set, SFTP clients are confined to this directory. The client's `/`
/// is the chroot root (OpenSSH `ChrootDirectory` re-rooting semantics):
/// - Both absolute and relative client paths are re-anchored under `root`,
/// so `/subdir` resolves to `<root>/subdir`.
/// - A host-looking path such as `/etc/passwd` is confined to
/// `<root>/etc/passwd`, never the host file.
/// - `..` traversal is clamped to `root` (it can never escape), and
/// intermediate-directory symlinks pointing outside the chroot are
/// rejected.
///
/// When `None` (default), no chroot is applied. This matches OpenSSH
/// `sftp-server` behavior: absolute paths are used verbatim and relative
Expand Down
95 changes: 50 additions & 45 deletions src/server/scp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,32 +231,19 @@ fn normalize_components(path: &Path) -> PathBuf {

/// Resolve a client-supplied SCP path against a chroot root.
///
/// - Absolute paths inside `root` are honored as-is.
/// - Absolute paths outside `root` are rejected.
/// - Relative paths are joined with `root`; `..` is clamped to `root`.
/// Under chroot the client's `/` is the chroot root (OpenSSH `ChrootDirectory`
/// re-rooting semantics, matching `sftp.root`):
///
/// - Both absolute and relative client paths are re-anchored under `root`, so
/// `/subdir` maps to `<root>/subdir` and plain `/` maps to `root`.
/// - A host-looking path such as `/etc/passwd` is confined to
/// `<root>/etc/passwd`, never the host file.
/// - `..` traversal is clamped to `root` (it can never escape).
fn resolve_chroot_scp(requested: &Path, root: &Path, user: &str) -> Result<PathBuf> {
if requested.is_absolute() {
// Plain "/" is the client's view of the chroot root (matches what
// `realpath` returns). Map it back to the actual chroot directory.
if requested == Path::new("/") {
return Ok(root.to_path_buf());
}
let normalized = normalize_components(requested);
if normalized == root || normalized.starts_with(root) {
return Ok(normalized);
}
tracing::warn!(
event = "chroot_escape_blocked",
user = %user,
requested = %requested.display(),
root = %root.display(),
"Security: absolute path outside chroot blocked"
);
anyhow::bail!("Access denied: path outside root");
}

// Relative path under chroot: join, then walk components clamping `..`
// so traversal cannot escape the chroot.
// Re-anchor the client path under `root`: a leading "/" denotes the chroot
// root (not a host-absolute path), so absolute and relative paths alike
// walk from `root`, dropping "." and clamping ".." so traversal cannot
// escape the chroot.
let mut resolved = root.to_path_buf();
for component in requested.components() {
match component {
Expand Down Expand Up @@ -427,9 +414,12 @@ impl ScpHandler {
/// Behavior depends on whether a chroot `root_dir` is configured.
///
/// ## With chroot (`root_dir = Some(root)`):
/// - Absolute client paths inside `root` are honored as-is.
/// - Absolute client paths outside `root` are rejected.
/// - Relative paths are joined with `root`; `..` traversal is clamped.
/// - The client's `/` is the chroot root, so both absolute and relative
/// client paths are re-anchored under `root` (OpenSSH `ChrootDirectory`
/// semantics, matching `sftp.root`): `/subdir` resolves to
/// `<root>/subdir`, and a host-looking `/etc/passwd` is confined to
/// `<root>/etc/passwd`.
/// - `..` traversal is clamped to `root` (cannot escape).
/// - Existing paths are canonicalized to catch symlink-escape attempts.
/// - For non-existent paths (typical for new-file creates), the closest
/// existing ancestor is canonicalized and verified to stay inside
Expand Down Expand Up @@ -1353,30 +1343,45 @@ mod tests {
}

#[test]
fn chroot_absolute_inside_root_is_returned_verbatim() {
// Bug fix: an absolute client path inside the chroot must NOT be
// re-rooted under itself. /home/testuser/file.bin must resolve to
// /home/testuser/file.bin, not /home/testuser/home/testuser/file.bin.
let handler = chroot_handler(PathBuf::from("/home/testuser/file.bin"));
fn chroot_absolute_path_is_reanchored_under_root() {
// Under chroot the client's "/" IS the chroot root, so an absolute
// client path like "/file.bin" means "<root>/file.bin" and is
// re-anchored under the root (matching sftp.root and OpenSSH
// ChrootDirectory), not treated as a host path.
let handler = chroot_handler(PathBuf::from("/file.bin"));
let result = handler.resolve_path(Path::new("/file.bin")).unwrap();
assert_eq!(result, PathBuf::from("/home/testuser/file.bin"));

let result = handler
.resolve_path(Path::new("/home/testuser/file.bin"))
.resolve_path(Path::new("/documents/file.txt"))
.unwrap();
assert_eq!(result, PathBuf::from("/home/testuser/file.bin"));
assert_eq!(result, PathBuf::from("/home/testuser/documents/file.txt"));
}

#[test]
fn chroot_absolute_outside_root_is_rejected() {
fn chroot_absolute_host_path_is_confined_not_escaped() {
// A client cannot reach the host filesystem: an absolute path is
// confined under the chroot, so "/etc/passwd" maps to
// "<root>/etc/passwd" (which does not exist) rather than the host's
// /etc/passwd. Rejecting absolute paths outright, as before, also broke
// every legitimate SCP path under chroot; re-anchoring both fixes that
// and unifies SCP with sftp.root.
let handler = chroot_handler(PathBuf::from("/etc/passwd"));
let err = handler.resolve_path(Path::new("/etc/passwd")).unwrap_err();
assert!(
err.to_string().contains("outside root"),
"expected rejection, got: {err}"
assert_eq!(
handler.resolve_path(Path::new("/etc/passwd")).unwrap(),
PathBuf::from("/home/testuser/etc/passwd")
);
assert_eq!(
handler.resolve_path(Path::new("/tmp/file.bin")).unwrap(),
PathBuf::from("/home/testuser/tmp/file.bin")
);
// `..` cannot climb above the root even when prefixed with the host path.
assert_eq!(
handler
.resolve_path(Path::new("/home/testuser/../../etc/passwd"))
.unwrap(),
PathBuf::from("/home/testuser/etc/passwd")
);

let err = handler
.resolve_path(Path::new("/tmp/file.bin"))
.unwrap_err();
assert!(err.to_string().contains("outside root"));
}

#[test]
Expand Down
Loading