fixes SharpCompress TAR extraction overwrites files outside the extraction root when a SymbolicLinkHandler enables symlink chaining

This commit is contained in:
Adam Hathcock
2026-08-05 13:44:19 +01:00
parent 67bd9289f9
commit 513b6bec45
7 changed files with 475 additions and 12 deletions

View File

@@ -1,3 +1,4 @@
using System;
using System.IO;
namespace SharpCompress.Common;
@@ -8,6 +9,10 @@ internal static class DirectoryManagement
"Entry is trying to create a directory outside of the destination directory.";
internal const string WriteFileOutsideDestinationMessage =
"Entry is trying to write a file outside of the destination directory.";
internal const string LinkTargetOutsideDestinationMessage =
"Entry is trying to create a symbolic link outside of the destination directory.";
internal const string ReparsePointInDestinationMessage =
"Entry is trying to extract through a symbolic link or reparse point.";
internal static string GetFullDestinationDirectoryPath(string destinationDirectory)
{
@@ -58,6 +63,114 @@ internal static class DirectoryManagement
throw new ExtractionException(exceptionMessage);
}
internal static void EnsureNoReparsePointInDestinationDirectory(
string destinationPath,
string fullDestinationDirectoryPath
)
{
var destinationDirectoryPath = TrimTrailingDirectorySeparators(
fullDestinationDirectoryPath
);
EnsurePathIsNotReparsePoint(destinationDirectoryPath);
if (string.Equals(destinationPath, destinationDirectoryPath, Utility.PathComparison))
{
return;
}
var relativeDestinationPath = destinationPath.Substring(
fullDestinationDirectoryPath.Length
);
var path = destinationDirectoryPath;
foreach (
var pathPart in relativeDestinationPath.Split(
new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
StringSplitOptions.RemoveEmptyEntries
)
)
{
path = Path.Combine(path, pathPart);
if (!PathExistsAndIsNotReparsePoint(path))
{
return;
}
}
}
internal static void CreateDirectory(
string destinationPath,
string fullDestinationDirectoryPath
)
{
EnsureNoReparsePointInDestinationDirectory(destinationPath, fullDestinationDirectoryPath);
if (!Directory.Exists(destinationPath))
{
Directory.CreateDirectory(destinationPath);
}
EnsureNoReparsePointInDestinationDirectory(destinationPath, fullDestinationDirectoryPath);
}
internal static void EnsureLinkTargetInDestinationDirectory(
string destinationFileName,
string linkTarget,
string fullDestinationDirectoryPath
)
{
var destinationDirectory = Path.GetDirectoryName(destinationFileName)
.NotNull("Destination directory is null");
var fullLinkTargetPath = Path.GetFullPath(Path.Combine(destinationDirectory, linkTarget));
EnsurePathInDestinationDirectory(
fullLinkTargetPath,
fullDestinationDirectoryPath,
LinkTargetOutsideDestinationMessage
);
}
internal static void EnsurePathIsNotReparsePoint(string path)
{
PathExistsAndIsNotReparsePoint(path);
}
private static bool PathExistsAndIsNotReparsePoint(string path)
{
try
{
if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
{
throw new ExtractionException(ReparsePointInDestinationMessage);
}
return true;
}
catch (FileNotFoundException)
{
return false;
}
catch (DirectoryNotFoundException)
{
return false;
}
catch (UnauthorizedAccessException exception)
{
throw new ExtractionException(
"Unable to verify the extraction path for symbolic links or reparse points.",
exception
);
}
catch (IOException exception)
{
throw new ExtractionException(
"Unable to verify the extraction path for symbolic links or reparse points.",
exception
);
}
}
private static bool IsDirectorySeparator(char value) =>
value == Path.DirectorySeparatorChar || value == Path.AltDirectorySeparatorChar;

View File

@@ -60,6 +60,8 @@ public sealed record ExtractionOptions : IExtractionOptions
/// <remarks>
/// <b>Breaking change:</b> Changed from field to property in version 0.40.0.
/// If no handler is provided, symbolic links are silently skipped during extraction.
/// Directory extraction rejects link targets outside the destination directory and does not
/// follow symbolic links or reparse points in later entry paths.
/// </remarks>
public Action<string, string>? SymbolicLinkHandler { get; set; }

View File

@@ -38,6 +38,11 @@ internal static partial class IEntryExtensions
CancellationToken cancellationToken = default
)
{
if (entry.LinkTarget is not null && options.SymbolicLinkHandler is null)
{
return;
}
var destinationFileName = GetEntryDestinationFileName(
entry,
fullDestinationDirectoryPath,
@@ -54,6 +59,20 @@ internal static partial class IEntryExtensions
DirectoryManagement.WriteFileOutsideDestinationMessage
);
DirectoryManagement.EnsureNoReparsePointInDestinationDirectory(
destinationFileName,
fullDestinationDirectoryPath
);
if (entry.LinkTarget is not null)
{
DirectoryManagement.EnsureLinkTargetInDestinationDirectory(
destinationFileName,
entry.LinkTarget,
fullDestinationDirectoryPath
);
}
if (writeAsync != null)
{
await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false);
@@ -69,10 +88,10 @@ internal static partial class IEntryExtensions
DirectoryManagement.CreateDirectoryOutsideDestinationMessage
);
if (!Directory.Exists(destinationFileName))
{
Directory.CreateDirectory(destinationFileName);
}
DirectoryManagement.CreateDirectory(
destinationFileName,
fullDestinationDirectoryPath
);
}
}
@@ -90,6 +109,8 @@ internal static partial class IEntryExtensions
}
else
{
DirectoryManagement.EnsurePathIsNotReparsePoint(destinationFileName);
var fm = FileMode.Create;
if (!options.Overwrite)

View File

@@ -45,6 +45,11 @@ internal static partial class IEntryExtensions
Action<string>? write
)
{
if (entry.LinkTarget is not null && options.SymbolicLinkHandler is null)
{
return;
}
var destinationFileName = GetEntryDestinationFileName(
entry,
fullDestinationDirectoryPath,
@@ -60,6 +65,21 @@ internal static partial class IEntryExtensions
fullDestinationDirectoryPath,
DirectoryManagement.WriteFileOutsideDestinationMessage
);
DirectoryManagement.EnsureNoReparsePointInDestinationDirectory(
destinationFileName,
fullDestinationDirectoryPath
);
if (entry.LinkTarget is not null)
{
DirectoryManagement.EnsureLinkTargetInDestinationDirectory(
destinationFileName,
entry.LinkTarget,
fullDestinationDirectoryPath
);
}
write?.Invoke(destinationFileName);
}
else if (options.ExtractFullPath)
@@ -72,10 +92,10 @@ internal static partial class IEntryExtensions
DirectoryManagement.CreateDirectoryOutsideDestinationMessage
);
if (!Directory.Exists(destinationFileName))
{
Directory.CreateDirectory(destinationFileName);
}
DirectoryManagement.CreateDirectory(
destinationFileName,
fullDestinationDirectoryPath
);
}
}
@@ -102,10 +122,7 @@ internal static partial class IEntryExtensions
: DirectoryManagement.WriteFileOutsideDestinationMessage
);
if (!Directory.Exists(destdir))
{
Directory.CreateDirectory(destdir);
}
DirectoryManagement.CreateDirectory(destdir, fullDestinationDirectoryPath);
return Path.Combine(destdir, file);
}
@@ -126,6 +143,8 @@ internal static partial class IEntryExtensions
}
else
{
DirectoryManagement.EnsurePathIsNotReparsePoint(destinationFileName);
var fm = FileMode.Create;
if (!options.Overwrite)

View File

@@ -39,6 +39,8 @@ public interface IExtractionOptions
/// Delegate for writing symbolic links to disk.
/// The first parameter is the source path (where the symlink is created).
/// The second parameter is the target path (what the symlink refers to).
/// Directory extraction rejects link targets outside the destination directory and does not
/// follow symbolic links or reparse points in later entry paths.
/// </summary>
Action<string, string>? SymbolicLinkHandler { get; set; }
}