@@ -68,6 +68,10 @@ const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024 * 1024;
6868/// Buffer size for file transfers (64 KB).
6969const BUFFER_SIZE : usize = 64 * 1024 ;
7070
71+ /// Maximum line length for SCP protocol headers (64 KB).
72+ /// This prevents DoS via unbounded buffer growth.
73+ const MAX_LINE_LENGTH : usize = 64 * 1024 ;
74+
7175/// SCP operation mode.
7276#[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
7377pub enum ScpMode {
@@ -270,6 +274,7 @@ impl ScpHandler {
270274 /// 1. Joining the path with the root directory
271275 /// 2. Normalizing the path (resolving "." and ".." components)
272276 /// 3. Verifying the result is within the root directory
277+ /// 4. If the path exists, canonicalizing to catch symlink attacks
273278 pub fn resolve_path ( & self , path : & Path ) -> Result < PathBuf > {
274279 let path_str = path. to_string_lossy ( ) ;
275280
@@ -306,14 +311,48 @@ impl ScpHandler {
306311 // Ensure the resolved path is within the root
307312 if !resolved. starts_with ( & self . root_dir ) {
308313 tracing:: warn!(
314+ event = "path_traversal_attempt" ,
315+ user = %self . user_info. username,
309316 requested = %path_str,
310317 resolved = %resolved. display( ) ,
311318 root = %self . root_dir. display( ) ,
312- "Path traversal attempt detected "
319+ "Security: path traversal attempt blocked "
313320 ) ;
314321 anyhow:: bail!( "Access denied: path outside root" ) ;
315322 }
316323
324+ // If the path exists, canonicalize it to catch symlink attacks
325+ // This prevents an attacker from creating symlinks that point outside the root
326+ if resolved. exists ( ) {
327+ match std:: fs:: canonicalize ( & resolved) {
328+ Ok ( canonical) => {
329+ if !canonical. starts_with ( & self . root_dir ) {
330+ tracing:: warn!(
331+ event = "symlink_escape_attempt" ,
332+ user = %self . user_info. username,
333+ requested = %path_str,
334+ resolved = %resolved. display( ) ,
335+ canonical = %canonical. display( ) ,
336+ root = %self . root_dir. display( ) ,
337+ "Security: symlink escape attempt blocked"
338+ ) ;
339+ anyhow:: bail!( "Access denied: symlink target outside root" ) ;
340+ }
341+ // Use the canonical path for existing files
342+ return Ok ( canonical) ;
343+ }
344+ Err ( e) => {
345+ // If canonicalization fails, proceed with the resolved path
346+ // This handles broken symlinks and permission issues
347+ tracing:: debug!(
348+ path = %resolved. display( ) ,
349+ error = %e,
350+ "Canonicalization failed, using resolved path"
351+ ) ;
352+ }
353+ }
354+ }
355+
317356 tracing:: trace!(
318357 requested = %path_str,
319358 resolved = %resolved. display( ) ,
@@ -504,21 +543,32 @@ impl ScpHandler {
504543 anyhow:: bail!( "Invalid file header: {}" , header) ;
505544 }
506545
507- let mode = u32:: from_str_radix ( parts[ 0 ] , 8 )
546+ let raw_mode = u32:: from_str_radix ( parts[ 0 ] , 8 )
508547 . with_context ( || format ! ( "Invalid mode: {}" , parts[ 0 ] ) ) ?;
548+ // Security: mask mode to only allow standard permission bits
549+ // Prevents setuid (04000), setgid (02000), and sticky (01000) bits
550+ let mode = raw_mode & 0o777 ;
551+
509552 let size: u64 = parts[ 1 ]
510553 . parse ( )
511554 . with_context ( || format ! ( "Invalid size: {}" , parts[ 1 ] ) ) ?;
512555 let filename = parts[ 2 ] . trim ( ) ;
513556
514557 // Security: validate filename
515558 if filename. contains ( '/' ) || filename == ".." || filename == "." {
516- anyhow:: bail!( "Invalid filename: {}" , filename ) ;
559+ anyhow:: bail!( "Invalid filename" ) ;
517560 }
518561
519562 // Check file size limit
520563 if size > MAX_FILE_SIZE {
521- anyhow:: bail!( "File too large: {} bytes (max: {} bytes)" , size, MAX_FILE_SIZE ) ;
564+ tracing:: warn!(
565+ event = "file_size_exceeded" ,
566+ user = %self . user_info. username,
567+ size = %size,
568+ max_size = %MAX_FILE_SIZE ,
569+ "Security: file size limit exceeded"
570+ ) ;
571+ anyhow:: bail!( "File too large" ) ;
522572 }
523573
524574 let target_path = target_dir. join ( filename) ;
@@ -550,7 +600,6 @@ impl ScpHandler {
550600
551601 // Receive file data
552602 let mut remaining = size;
553- let mut _write_buffer: Vec < u8 > = Vec :: with_capacity ( BUFFER_SIZE ) ;
554603
555604 // First, use any data already in buffer
556605 let buffered = buffer. len ( ) . min ( remaining as usize ) ;
@@ -663,20 +712,30 @@ impl ScpHandler {
663712 anyhow:: bail!( "Access denied: path outside root" ) ;
664713 }
665714
715+ // Mask mode to only allow standard permission bits (no setuid/setgid/sticky)
716+ let safe_mode = mode & 0o777 ;
717+
666718 tracing:: debug!(
667719 user = %self . user_info. username,
668720 path = %new_dir. display( ) ,
669- mode = format!( "{:04o}" , mode ) ,
721+ mode = format!( "{:04o}" , safe_mode ) ,
670722 "Entering directory"
671723 ) ;
672724
673- // Create directory if it doesn't exist
674- if !new_dir. exists ( ) {
675- fs:: create_dir ( & new_dir) . await ?;
676- #[ cfg( unix) ]
677- {
678- fs:: set_permissions ( & new_dir, std:: fs:: Permissions :: from_mode ( mode) ) . await ?;
725+ // Create directory atomically - handles race conditions safely
726+ // If directory already exists, that's fine; we just continue
727+ match fs:: create_dir ( & new_dir) . await {
728+ Ok ( ( ) ) => {
729+ #[ cfg( unix) ]
730+ {
731+ fs:: set_permissions ( & new_dir, std:: fs:: Permissions :: from_mode ( safe_mode) )
732+ . await ?;
733+ }
734+ }
735+ Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: AlreadyExists => {
736+ // Directory already exists, which is acceptable
679737 }
738+ Err ( e) => return Err ( e. into ( ) ) ,
680739 }
681740
682741 Ok ( new_dir)
@@ -920,6 +979,14 @@ impl ScpHandler {
920979 return Ok ( Some ( line) ) ;
921980 }
922981
982+ // Check for buffer size limit to prevent DoS via memory exhaustion
983+ if buffer. len ( ) > MAX_LINE_LENGTH {
984+ anyhow:: bail!(
985+ "Line too long (max {} bytes) - possible DoS attempt" ,
986+ MAX_LINE_LENGTH
987+ ) ;
988+ }
989+
923990 // Read more data
924991 match data_rx. recv ( ) . await {
925992 Some ( data) => {
0 commit comments