@@ -370,7 +370,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
370370 string ? fileDestinationPath = GetFullDestinationPath (
371371 destinationDirectoryPath ,
372372 Path . IsPathFullyQualified ( name ) ? name : Path . Join ( destinationDirectoryPath , name ) ) ;
373- if ( fileDestinationPath == null )
373+ if ( fileDestinationPath is null || FilePathEscapesDirectory ( destinationDirectoryPath , fileDestinationPath ) )
374374 {
375375 throw new IOException ( SR . Format ( SR . TarExtractingResultsFileOutside , name , destinationDirectoryPath ) ) ;
376376 }
@@ -391,7 +391,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
391391 string ? linkDestination = GetFullDestinationPath (
392392 destinationDirectoryPath ,
393393 Path . IsPathFullyQualified ( linkName ) ? linkName : Path . Join ( Path . GetDirectoryName ( fileDestinationPath ) , linkName ) ) ;
394- if ( linkDestination is null )
394+ if ( linkDestination is null || FilePathEscapesDirectory ( destinationDirectoryPath , linkDestination ) )
395395 {
396396 throw new IOException ( SR . Format ( SR . TarExtractingResultsLinkOutside , linkName , destinationDirectoryPath ) ) ;
397397 }
@@ -406,7 +406,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
406406 string ? linkDestination = GetFullDestinationPath (
407407 destinationDirectoryPath ,
408408 Path . Join ( destinationDirectoryPath , linkName ) ) ;
409- if ( linkDestination is null )
409+ if ( linkDestination is null || FilePathEscapesDirectory ( destinationDirectoryPath , linkDestination ) )
410410 {
411411 throw new IOException ( SR . Format ( SR . TarExtractingResultsLinkOutside , linkName , destinationDirectoryPath ) ) ;
412412 }
@@ -417,6 +417,106 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
417417 return ( fileDestinationPath , linkTargetPath ) ;
418418 }
419419
420+ // Prevent an archive from escaping the extraction root through symlinks that were created by earlier entries in the same archive.
421+ // This protection applies only to links introduced by the archive itself. It is not intended to defend against preexisting symlinks
422+ // already present on disk before extraction
423+ private static bool FilePathEscapesDirectory ( string destinationDirectoryPath , string fileDestinationPath )
424+ {
425+ // Windows is case insensitive while Linux is case sensitive
426+ // This ensures the comparison is consistent with how the OS would resolve the paths
427+ StringComparison pathComparison = OperatingSystem . IsWindows ( )
428+ ? StringComparison . OrdinalIgnoreCase
429+ : StringComparison . Ordinal ;
430+
431+ string resolvedDest = ResolvePhysicalPath ( destinationDirectoryPath ) ;
432+
433+ // Use the logical destination path for computing the relative path
434+ string logicalDest = Path . GetFullPath ( destinationDirectoryPath ) ;
435+ string logicalPrefix = logicalDest . EndsWith ( Path . DirectorySeparatorChar )
436+ ? logicalDest
437+ : logicalDest + Path . DirectorySeparatorChar ;
438+
439+ string destPrefix = resolvedDest . EndsWith ( Path . DirectorySeparatorChar )
440+ ? resolvedDest
441+ : resolvedDest + Path . DirectorySeparatorChar ;
442+
443+ // Normalize file path (resolves .. and . but not symlinks)
444+ string normalizedFile = Path . GetFullPath ( fileDestinationPath ) ;
445+
446+ // Guard with StartsWith before computing relative path
447+ if ( ! normalizedFile . StartsWith ( logicalPrefix , pathComparison ) &&
448+ ! normalizedFile . Equals ( logicalDest , pathComparison ) )
449+ {
450+ return true ;
451+ }
452+
453+ // Walk relative components, resolving symlinks at each step
454+ string relative = normalizedFile . Substring ( logicalPrefix . Length )
455+ . TrimStart ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) ;
456+
457+ string [ ] components = relative . Split ( new char [ ] { Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar } ,
458+ StringSplitOptions . RemoveEmptyEntries ) ;
459+
460+ string current = resolvedDest ;
461+
462+ foreach ( string component in components )
463+ {
464+ current = Path . Combine ( current , component ) ;
465+ current = ResolveSymlink ( current ) ;
466+
467+ string normalizedCurrent = Path . GetFullPath ( current ) ;
468+ if ( ! normalizedCurrent . StartsWith ( destPrefix , pathComparison ) &&
469+ ! normalizedCurrent . Equals ( resolvedDest , pathComparison ) )
470+ {
471+ return true ;
472+ }
473+ }
474+
475+ return false ;
476+ }
477+
478+ private static string ResolveSymlink ( string path )
479+ {
480+ var info = new FileInfo ( path ) ;
481+
482+ // Check LinkTarget first so dangling symlinks/junctions (whose final target doesn't exist yet)
483+ // are still resolved to their raw target, rather than being treated as a non-link.
484+ if ( info . LinkTarget is null )
485+ {
486+ return Path . GetFullPath ( path ) ;
487+ }
488+
489+ FileSystemInfo target = info . ResolveLinkTarget ( returnFinalTarget : true ) ?? info ;
490+ return target . FullName ;
491+ }
492+
493+ // Resolves the full path of the specified path, resolving symlinks at each step.
494+ // This is needed to mitigate malicious entries in the archive that could lead to writing files outside of the intended directory.
495+ private static string ResolvePhysicalPath ( string path )
496+ {
497+ string fullPath = Path . GetFullPath ( path ) ;
498+ string ? root = Path . GetPathRoot ( fullPath ) ;
499+
500+ if ( root is null )
501+ {
502+ return fullPath ;
503+ }
504+
505+ string [ ] components = fullPath . Substring ( root . Length )
506+ . Split ( new char [ ] { Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar } , StringSplitOptions . RemoveEmptyEntries ) ;
507+ string current = root ;
508+ foreach ( string component in components )
509+ {
510+ current = Path . Combine ( current , component ) ;
511+ if ( Path . Exists ( current ) )
512+ {
513+ current = ResolveSymlink ( current ) ;
514+ }
515+ }
516+
517+ return current ;
518+ }
519+
420520 // Returns the full destination path if the path is the destinationDirectory or a subpath. Otherwise, returns null.
421521 private static string ? GetFullDestinationPath ( string destinationDirectoryFullPath , string qualifiedPath )
422522 {
0 commit comments