@@ -55,14 +55,50 @@ public override async IAsyncEnumerable<BlobMetadata> GetBlobMetadataListAsync(st
5555 if ( cancellationToken . IsCancellationRequested )
5656 yield break ;
5757
58- var blobMetadata = await GetBlobMetadataAsync ( file , cancellationToken ) ;
58+ var relativePath = Path . GetRelativePath ( StorageClient , file )
59+ . Replace ( '\\ ' , '/' ) ;
60+
61+ var ( relativeDirectory , relativeFileName ) = SplitRelativePath ( relativePath ) ;
62+ MetadataOptions options = new ( )
63+ {
64+ FileName = relativeFileName ,
65+ Directory = relativeDirectory
66+ } ;
67+
68+ var blobMetadata = await GetBlobMetadataAsync ( options , cancellationToken ) ;
5969 cancellationToken . ThrowIfCancellationRequested ( ) ;
6070
6171 if ( blobMetadata . IsSuccess )
72+ {
6273 yield return blobMetadata . Value ;
74+ continue ;
75+ }
6376 }
6477 }
6578
79+ private static ( string ? Directory , string FileName ) SplitRelativePath ( string relativePath )
80+ {
81+ if ( string . IsNullOrWhiteSpace ( relativePath ) )
82+ throw new ArgumentException ( "Relative path cannot be null or empty" , nameof ( relativePath ) ) ;
83+
84+ var normalizedPath = relativePath . Replace ( '\\ ' , '/' ) ;
85+ var separatorIndex = normalizedPath . LastIndexOf ( '/' ) ;
86+
87+ if ( separatorIndex < 0 )
88+ return ( null , normalizedPath ) ;
89+
90+ var directory = separatorIndex == 0
91+ ? null
92+ : normalizedPath [ ..separatorIndex ] ;
93+
94+ var fileName = normalizedPath [ ( separatorIndex + 1 ) ..] ;
95+
96+ if ( string . IsNullOrWhiteSpace ( fileName ) )
97+ throw new InvalidOperationException ( $ "Invalid relative path: '{ relativePath } '") ;
98+
99+ return ( string . IsNullOrWhiteSpace ( directory ) ? null : directory , fileName ) ;
100+ }
101+
66102 public override async Task < Result < Stream > > GetStreamAsync ( string fileName , CancellationToken cancellationToken = default )
67103 {
68104 try
@@ -315,19 +351,147 @@ protected override async Task<Result<bool>> HasLegalHoldInternalAsync(LegalHoldO
315351
316352 private string GetPathFromOptions ( BaseOptions options )
317353 {
318- string filePath ;
319- if ( options . Directory is not null )
354+ if ( string . IsNullOrWhiteSpace ( options . FileName ) )
355+ throw new ArgumentException ( "File name cannot be null or empty" , nameof ( options . FileName ) ) ;
356+
357+ var ( directoryFromFileName , fileNameOnly ) = SplitDirectoryFromFileName ( options . FileName ) ;
358+
359+ // Sanitize and validate components
360+ var sanitizedFileName = SanitizeFileName ( fileNameOnly ) ;
361+
362+ var combinedDirectory = CombineDirectoryParts ( options . Directory , directoryFromFileName ) ;
363+ var sanitizedDirectory = combinedDirectory is not null
364+ ? SanitizeDirectory ( combinedDirectory )
365+ : null ;
366+
367+ if ( sanitizedDirectory is not null )
320368 {
321- EnsureDirectoryExist ( options . Directory ) ;
322- filePath = Path . Combine ( StorageClient , options . Directory , options . FileName ) ;
369+ EnsureDirectoryExist ( sanitizedDirectory ) ;
323370 }
324- else
371+
372+ string filePath = sanitizedDirectory is not null
373+ ? Path . Combine ( StorageClient , sanitizedDirectory , sanitizedFileName )
374+ : Path . Combine ( StorageClient , sanitizedFileName ) ;
375+
376+ // Get full paths for comparison
377+ var fullPath = Path . GetFullPath ( filePath ) ;
378+ var baseFullPath = Path . GetFullPath ( StorageClient ) ;
379+
380+ // Verify the final path is within StorageClient directory
381+ if ( ! fullPath . StartsWith ( baseFullPath , StringComparison . OrdinalIgnoreCase ) )
325382 {
326- filePath = Path . Combine ( StorageClient , options . FileName ) ;
383+ throw new UnauthorizedAccessException ( $ "Access to path '{ options . FileName } ' is denied. Path traversal detected.") ;
384+ }
385+
386+ EnsureDirectoryExist ( Path . GetDirectoryName ( fullPath ) ! ) ;
387+ return fullPath ;
388+ }
389+
390+ private static ( string ? Directory , string FileName ) SplitDirectoryFromFileName ( string fileName )
391+ {
392+ var normalized = fileName . Replace ( '\\ ' , '/' ) ;
393+ var lastSlash = normalized . LastIndexOf ( '/' ) ;
394+
395+ if ( lastSlash < 0 )
396+ return ( null , normalized ) ;
397+
398+ var directory = normalized [ ..lastSlash ] ;
399+ var name = normalized [ ( lastSlash + 1 ) ..] ;
400+ return ( directory , name ) ;
401+ }
402+
403+ private static string ? CombineDirectoryParts ( string ? primary , string ? secondary )
404+ {
405+ var parts = new List < string > ( ) ;
406+
407+ void AddPart ( string ? value )
408+ {
409+ if ( string . IsNullOrWhiteSpace ( value ) )
410+ return ;
411+
412+ var normalized = value . Replace ( '\\ ' , '/' ) ;
413+ foreach ( var segment in normalized . Split ( '/' , StringSplitOptions . RemoveEmptyEntries ) )
414+ {
415+ parts . Add ( segment ) ;
416+ }
417+ }
418+
419+ AddPart ( primary ) ;
420+ AddPart ( secondary ) ;
421+
422+ if ( parts . Count == 0 )
423+ return null ;
424+
425+ return string . Join ( '/' , parts ) ;
426+ }
427+
428+ private static string SanitizeFileName ( string fileName )
429+ {
430+ if ( string . IsNullOrWhiteSpace ( fileName ) )
431+ throw new ArgumentException ( "File name cannot be null or empty" , nameof ( fileName ) ) ;
432+
433+ var originalFileName = fileName ;
434+
435+ // Check for path traversal attempts - throw exception if detected
436+ if ( fileName . Contains ( ".." , StringComparison . Ordinal ) )
437+ throw new UnauthorizedAccessException ( $ "Access to path '{ originalFileName } ' is denied. Path traversal detected.") ;
438+
439+ // If there are path separators, extract only the filename part
440+ // This handles cases like /tmp/file.txt -> file.txt
441+ if ( fileName . Contains ( '/' ) || fileName . Contains ( '\\ ' ) )
442+ {
443+ fileName = Path . GetFileName ( fileName ) ;
444+ }
445+
446+ if ( string . IsNullOrWhiteSpace ( fileName ) )
447+ throw new ArgumentException ( "Invalid file name" , nameof ( fileName ) ) ;
448+
449+ // Remove any invalid filename characters
450+ var invalidChars = Path . GetInvalidFileNameChars ( ) ;
451+ var sanitized = fileName ;
452+ foreach ( var c in invalidChars )
453+ {
454+ sanitized = sanitized . Replace ( c . ToString ( ) , string . Empty ) ;
455+ }
456+
457+ if ( string . IsNullOrWhiteSpace ( sanitized ) )
458+ throw new ArgumentException ( "File name contains only invalid characters" , nameof ( fileName ) ) ;
459+
460+ return sanitized ;
461+ }
462+
463+ private static string SanitizeDirectory ( string directory )
464+ {
465+ if ( string . IsNullOrWhiteSpace ( directory ) )
466+ return string . Empty ;
467+
468+ var originalDirectory = directory ;
469+
470+ // Check for path traversal attempts - throw exception if detected
471+ if ( directory . Contains ( ".." , StringComparison . Ordinal ) )
472+ throw new UnauthorizedAccessException ( $ "Access to path '{ originalDirectory } ' is denied. Path traversal detected.") ;
473+
474+ // Normalize path separators
475+ directory = directory . Replace ( '\\ ' , '/' ) ;
476+
477+ // Remove leading and trailing slashes
478+ directory = directory . Trim ( '/' ) ;
479+
480+ // Validate each directory segment
481+ var segments = directory . Split ( '/' , StringSplitOptions . RemoveEmptyEntries ) ;
482+ var invalidChars = Path . GetInvalidFileNameChars ( ) ;
483+
484+ foreach ( var segment in segments )
485+ {
486+ if ( segment . IndexOfAny ( invalidChars ) >= 0 )
487+ throw new ArgumentException ( $ "Directory path contains invalid characters: { segment } ", nameof ( directory ) ) ;
488+
489+ // Additional check for suspicious segments
490+ if ( segment == ".." || segment == "." )
491+ throw new UnauthorizedAccessException ( $ "Access to path '{ originalDirectory } ' is denied. Path traversal detected.") ;
327492 }
328493
329- EnsureDirectoryExist ( Path . GetDirectoryName ( filePath ) ! ) ;
330- return filePath ;
494+ return string . Join ( Path . DirectorySeparatorChar , segments ) ;
331495 }
332496
333497 private void EnsureDirectoryExist ( string directory )
0 commit comments