Skip to content

Commit 74e6f2e

Browse files
authored
feat: Implement SFTP server handler with path traversal prevention (#132) (#151)
* feat: Implement SFTP server handler with path traversal prevention Implement the SFTP server subsystem using russh-sftp library for file transfer operations. The handler provides secure file operations with chroot-like path resolution that prevents clients from accessing files outside their designated root directory. * fix: Address critical security vulnerabilities in SFTP server Fix multiple CRITICAL and HIGH security issues in the SFTP server implementation: CRITICAL fixes: - Validate symlink targets in symlink() to prevent path traversal via malicious symlinks - Validate symlink targets in open() and stat() operations before following them - Prevent symlinks from pointing outside the root directory HIGH fixes: - Redact absolute symlink targets in readlink() that point outside root - Prevent ".." directory entry from leaking parent directory metadata outside root - At root boundary, use root's own metadata for ".." instead of actual parent MEDIUM fixes: - Add maximum handle limit (1000) to prevent resource exhaustion - Cap read buffer size to 64KB to prevent memory exhaustion Security improvements: - Extract resolve_path_static() helper for reuse in symlink validation - Use symlink_metadata() instead of metadata() where appropriate - Add comprehensive validation before creating or following symlinks - Improve logging for security-related operations All changes maintain backward compatibility and pass existing tests. * test: Add comprehensive SFTP handler test coverage - Add tests for symlink handling in build_longname - Add tests for edge cases: empty paths, special characters, encoded paths - Add tests for all SftpError helper methods and conversions - Add tests for static path resolution method - Add tests for metadata_to_attrs function - Update ARCHITECTURE.md with SFTP handler documentation - Update docs/architecture/README.md with SFTP handler reference - Fix code formatting issues in sftp.rs
1 parent fb7759b commit 74e6f2e

6 files changed

Lines changed: 1753 additions & 9 deletions

File tree

ARCHITECTURE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ SSH server implementation using the russh library for accepting incoming connect
248248
- `handler.rs` - `SshHandler` implementing `russh::server::Handler` trait
249249
- `session.rs` - Session state management (`SessionManager`, `SessionInfo`, `ChannelState`)
250250
- `exec.rs` - Command execution for SSH exec requests
251+
- `sftp.rs` - SFTP subsystem handler with path traversal prevention
251252
- `auth/` - Authentication provider infrastructure
252253

253254
**Key Components**:
@@ -301,6 +302,17 @@ SSH server implementation using the russh library for accepting incoming connect
301302
- Idle session management
302303
- Authentication state tracking
303304

305+
- **SftpHandler**: SFTP subsystem handler (`src/server/sftp.rs`)
306+
- Implements `russh_sftp::server::Handler` trait for file transfer operations
307+
- Path traversal prevention with chroot-like isolation
308+
- File operations: open, read, write, close
309+
- Directory operations: opendir, readdir, mkdir, rmdir
310+
- Attribute operations: stat, lstat, fstat, setstat, fsetstat
311+
- Path operations: realpath, rename, remove, readlink, symlink
312+
- Symlink validation ensures targets remain within root directory
313+
- Handle limit enforcement to prevent resource exhaustion
314+
- Read size capping to prevent memory exhaustion
315+
304316
### Server Authentication Module
305317

306318
The authentication subsystem (`src/server/auth/`) provides extensible authentication for the SSH server:

docs/architecture/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ bssh is a high-performance parallel SSH command execution tool with SSH-compatib
3636
- **Server CLI (`bssh-server`)** - Server management commands including host key generation, password hashing, config validation (see main ARCHITECTURE.md)
3737
- **SSH Server Module** - SSH server implementation using russh (see main ARCHITECTURE.md)
3838
- **Server Authentication** - Authentication providers including public key verification (see main ARCHITECTURE.md)
39+
- **SFTP Handler** - SFTP subsystem with path traversal prevention and chroot-like isolation (see main ARCHITECTURE.md)
3940

4041
## Navigation
4142

src/server/handler.rs

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use super::auth::AuthProvider;
3131
use super::config::ServerConfig;
3232
use super::exec::CommandExecutor;
3333
use super::session::{ChannelState, PtyConfig, SessionId, SessionInfo, SessionManager};
34+
use super::sftp::SftpHandler;
3435
use crate::shared::rate_limit::RateLimiter;
3536

3637
/// SSH handler for a single client connection.
@@ -186,11 +187,13 @@ impl russh::server::Handler for SshHandler {
186187
let channel_id = channel.id();
187188
tracing::debug!(
188189
peer = ?self.peer_addr,
190+
channel = ?channel_id,
189191
"Channel opened for session"
190192
);
191193

194+
// Store the channel itself so we can use it for subsystems like SFTP
192195
self.channels
193-
.insert(channel_id, ChannelState::new(channel_id));
196+
.insert(channel_id, ChannelState::with_channel(channel));
194197
async { Ok(true) }
195198
}
196199

@@ -626,7 +629,8 @@ impl russh::server::Handler for SshHandler {
626629

627630
/// Handle subsystem request.
628631
///
629-
/// Placeholder implementation - will be implemented in a future issue.
632+
/// Handles SFTP subsystem requests by creating an SftpHandler and running
633+
/// the SFTP server on the channel stream.
630634
fn subsystem_request(
631635
&mut self,
632636
channel_id: ChannelId,
@@ -635,19 +639,106 @@ impl russh::server::Handler for SshHandler {
635639
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
636640
tracing::debug!(
637641
subsystem = %name,
642+
channel = ?channel_id,
643+
peer = ?self.peer_addr,
638644
"Subsystem request"
639645
);
640646

647+
// Handle SFTP subsystem
641648
if name == "sftp" {
642-
if let Some(channel_state) = self.channels.get_mut(&channel_id) {
643-
channel_state.set_sftp();
649+
// Check if SFTP is enabled (default: enabled)
650+
// In future, this should check config.sftp.enabled
651+
652+
// Get the channel from our stored channels
653+
let channel = self.channels.get_mut(&channel_id).and_then(|state| {
654+
state.set_sftp();
655+
state.take_channel()
656+
});
657+
658+
let channel = match channel {
659+
Some(ch) => ch,
660+
None => {
661+
tracing::warn!(
662+
channel = ?channel_id,
663+
"SFTP request but channel not found or already taken"
664+
);
665+
let _ = session.channel_failure(channel_id);
666+
return async { Ok(()) }.boxed();
667+
}
668+
};
669+
670+
// Get authenticated user info
671+
let username = match self.session_info.as_ref().and_then(|s| s.user.clone()) {
672+
Some(user) => user,
673+
None => {
674+
tracing::warn!(
675+
channel = ?channel_id,
676+
"SFTP request without authenticated user"
677+
);
678+
let _ = session.channel_failure(channel_id);
679+
return async { Ok(()) }.boxed();
680+
}
681+
};
682+
683+
// Clone what we need for the async block
684+
let auth_provider = Arc::clone(&self.auth_provider);
685+
let peer_addr = self.peer_addr;
686+
687+
// Signal success before spawning the SFTP handler
688+
let _ = session.channel_success(channel_id);
689+
690+
return async move {
691+
// Get user info from auth provider
692+
let user_info = match auth_provider.get_user_info(&username).await {
693+
Ok(Some(info)) => info,
694+
Ok(None) => {
695+
tracing::error!(
696+
user = %username,
697+
"User not found after authentication for SFTP"
698+
);
699+
return Ok(());
700+
}
701+
Err(e) => {
702+
tracing::error!(
703+
user = %username,
704+
error = %e,
705+
"Failed to get user info for SFTP"
706+
);
707+
return Ok(());
708+
}
709+
};
710+
711+
tracing::info!(
712+
user = %username,
713+
peer = ?peer_addr,
714+
home = %user_info.home_dir.display(),
715+
"Starting SFTP session"
716+
);
717+
718+
// Create SFTP handler with user's home directory as root
719+
let sftp_handler = SftpHandler::new(user_info.clone(), Some(user_info.home_dir));
720+
721+
// Run SFTP server on the channel stream
722+
russh_sftp::server::run(channel.into_stream(), sftp_handler).await;
723+
724+
tracing::info!(
725+
user = %username,
726+
peer = ?peer_addr,
727+
"SFTP session ended"
728+
);
729+
730+
Ok(())
644731
}
732+
.boxed();
645733
}
646734

647-
// Placeholder - reject for now
648-
// Will be implemented in #132 for SFTP
735+
// Unknown subsystem - reject
736+
tracing::debug!(
737+
subsystem = %name,
738+
"Unknown subsystem, rejecting"
739+
);
649740
let _ = session.channel_failure(channel_id);
650-
async { Ok(()) }
741+
async { Ok(()) }.boxed()
651742
}
652743

653744
/// Handle incoming data from the client.

src/server/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub mod config;
4949
pub mod exec;
5050
pub mod handler;
5151
pub mod session;
52+
pub mod sftp;
5253

5354
use std::net::SocketAddr;
5455
use std::path::Path;

src/server/session.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ use std::net::SocketAddr;
3030
use std::sync::atomic::{AtomicU64, Ordering};
3131
use std::time::Instant;
3232

33-
use russh::ChannelId;
33+
use russh::server::Msg;
34+
use russh::{Channel, ChannelId};
3435

3536
/// Unique identifier for an SSH session.
3637
///
@@ -184,11 +185,13 @@ impl PtyConfig {
184185
/// State of an SSH channel.
185186
///
186187
/// Tracks the current mode and configuration of a channel.
187-
#[derive(Debug)]
188188
pub struct ChannelState {
189189
/// The channel ID.
190190
pub channel_id: ChannelId,
191191

192+
/// The underlying channel for subsystem communication.
193+
channel: Option<Channel<Msg>>,
194+
192195
/// Current operation mode.
193196
pub mode: ChannelMode,
194197

@@ -199,17 +202,46 @@ pub struct ChannelState {
199202
pub eof_received: bool,
200203
}
201204

205+
impl std::fmt::Debug for ChannelState {
206+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207+
f.debug_struct("ChannelState")
208+
.field("channel_id", &self.channel_id)
209+
.field("has_channel", &self.channel.is_some())
210+
.field("mode", &self.mode)
211+
.field("pty", &self.pty)
212+
.field("eof_received", &self.eof_received)
213+
.finish()
214+
}
215+
}
216+
202217
impl ChannelState {
203218
/// Create a new channel state.
204219
pub fn new(channel_id: ChannelId) -> Self {
205220
Self {
206221
channel_id,
222+
channel: None,
207223
mode: ChannelMode::Idle,
208224
pty: None,
209225
eof_received: false,
210226
}
211227
}
212228

229+
/// Create a new channel state with the underlying channel.
230+
pub fn with_channel(channel: Channel<Msg>) -> Self {
231+
Self {
232+
channel_id: channel.id(),
233+
channel: Some(channel),
234+
mode: ChannelMode::Idle,
235+
pty: None,
236+
eof_received: false,
237+
}
238+
}
239+
240+
/// Take the underlying channel (consumes it for use with subsystems).
241+
pub fn take_channel(&mut self) -> Option<Channel<Msg>> {
242+
self.channel.take()
243+
}
244+
213245
/// Check if the channel has a PTY attached.
214246
pub fn has_pty(&self) -> bool {
215247
self.pty.is_some()

0 commit comments

Comments
 (0)