mirror of
https://github.com/adamhathcock/sharpcompress.git
synced 2026-09-22 23:15:20 +00:00
Merge pull request #1313 from adamhathcock/adam/zip-slip-fix
This commit is contained in:
@@ -106,8 +106,7 @@ public static class IArchiveEntryExtensions
|
||||
string destinationDirectory,
|
||||
ExtractionOptions? options = null
|
||||
) =>
|
||||
ExtractionMethods.WriteEntryToDirectory(
|
||||
entry,
|
||||
entry.WriteEntryToDirectory(
|
||||
destinationDirectory,
|
||||
options,
|
||||
(path) => entry.WriteToFile(path, options)
|
||||
@@ -121,9 +120,8 @@ public static class IArchiveEntryExtensions
|
||||
ExtractionOptions? options = null,
|
||||
CancellationToken cancellationToken = default
|
||||
) =>
|
||||
await ExtractionMethods
|
||||
await entry
|
||||
.WriteEntryToDirectoryAsync(
|
||||
entry,
|
||||
destinationDirectory,
|
||||
options,
|
||||
async (path, ct) =>
|
||||
@@ -136,8 +134,7 @@ public static class IArchiveEntryExtensions
|
||||
/// Extract to specific file
|
||||
/// </summary>
|
||||
public void WriteToFile(string destinationFileName, ExtractionOptions? options = null) =>
|
||||
ExtractionMethods.WriteEntryToFile(
|
||||
entry,
|
||||
entry.WriteEntryToFile(
|
||||
destinationFileName,
|
||||
options,
|
||||
(x, fm) =>
|
||||
@@ -155,9 +152,8 @@ public static class IArchiveEntryExtensions
|
||||
ExtractionOptions? options = null,
|
||||
CancellationToken cancellationToken = default
|
||||
) =>
|
||||
await ExtractionMethods
|
||||
await entry
|
||||
.WriteEntryToFileAsync(
|
||||
entry,
|
||||
destinationFileName,
|
||||
options,
|
||||
async (x, fm, ct) =>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using SharpCompress.Common;
|
||||
using SharpCompress.Readers;
|
||||
|
||||
@@ -39,29 +37,27 @@ public static class IArchiveExtensions
|
||||
IProgress<ProgressReport>? progress
|
||||
)
|
||||
{
|
||||
options ??= new ExtractionOptions();
|
||||
var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(
|
||||
destinationDirectory
|
||||
);
|
||||
|
||||
var totalBytes = archive.TotalUncompressedSize;
|
||||
var bytesRead = 0L;
|
||||
var seenDirectories = new HashSet<string>();
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
var dirPath = Path.Combine(
|
||||
destinationDirectory,
|
||||
entry.Key.NotNull("Entry Key is null")
|
||||
);
|
||||
if (
|
||||
Path.GetDirectoryName(dirPath + "/") is { } parentDirectory
|
||||
&& seenDirectories.Add(dirPath)
|
||||
)
|
||||
{
|
||||
Directory.CreateDirectory(parentDirectory);
|
||||
}
|
||||
entry.WriteEntryToDirectoryCore(fullDestinationDirectoryPath, options, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.WriteToDirectory(destinationDirectory, options);
|
||||
entry.WriteEntryToDirectoryCore(
|
||||
fullDestinationDirectoryPath,
|
||||
options,
|
||||
path => entry.WriteToFile(path, options)
|
||||
);
|
||||
|
||||
bytesRead += entry.Size;
|
||||
progress?.Report(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SharpCompress.Common;
|
||||
@@ -15,7 +13,6 @@ public static class IAsyncArchiveExtensions
|
||||
/// <summary>
|
||||
/// Extract to specific directory asynchronously with progress reporting and cancellation support
|
||||
/// </summary>
|
||||
/// <param name="archive">The archive to extract.</param>
|
||||
/// <param name="destinationDirectory">The folder to extract into.</param>
|
||||
/// <param name="options">Extraction options.</param>
|
||||
/// <param name="progress">Optional progress reporter for tracking extraction progress.</param>
|
||||
@@ -59,9 +56,13 @@ public static class IAsyncArchiveExtensions
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
options ??= new ExtractionOptions();
|
||||
var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(
|
||||
destinationDirectory
|
||||
);
|
||||
|
||||
var totalBytes = await archive.TotalUncompressedSizeAsync().ConfigureAwait(false);
|
||||
var bytesRead = 0L;
|
||||
var seenDirectories = new HashSet<string>();
|
||||
|
||||
await foreach (var entry in archive.EntriesAsync.WithCancellation(cancellationToken))
|
||||
{
|
||||
@@ -69,22 +70,25 @@ public static class IAsyncArchiveExtensions
|
||||
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
var dirPath = Path.Combine(
|
||||
destinationDirectory,
|
||||
entry.Key.NotNull("Entry Key is null")
|
||||
);
|
||||
if (
|
||||
Path.GetDirectoryName(dirPath + "/") is { } parentDirectory
|
||||
&& seenDirectories.Add(dirPath)
|
||||
)
|
||||
{
|
||||
Directory.CreateDirectory(parentDirectory);
|
||||
}
|
||||
await entry
|
||||
.WriteEntryToDirectoryAsyncCore(
|
||||
fullDestinationDirectoryPath,
|
||||
options,
|
||||
null,
|
||||
cancellationToken
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
await entry
|
||||
.WriteToDirectoryAsync(destinationDirectory, options, cancellationToken)
|
||||
.WriteEntryToDirectoryAsyncCore(
|
||||
fullDestinationDirectoryPath,
|
||||
options,
|
||||
async (path, ct) =>
|
||||
await entry.WriteToFileAsync(path, options, ct).ConfigureAwait(false),
|
||||
cancellationToken
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
bytesRead += entry.Size;
|
||||
|
||||
77
src/SharpCompress/Common/DirectoryManagement.cs
Normal file
77
src/SharpCompress/Common/DirectoryManagement.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.IO;
|
||||
|
||||
namespace SharpCompress.Common;
|
||||
|
||||
internal static class DirectoryManagement
|
||||
{
|
||||
internal const string CreateDirectoryOutsideDestinationMessage =
|
||||
"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 static string GetFullDestinationDirectoryPath(string destinationDirectory)
|
||||
{
|
||||
var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory);
|
||||
|
||||
// Keep the trailing separator so prefix checks cannot match sibling directories.
|
||||
if (
|
||||
!IsDirectorySeparator(
|
||||
fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1]
|
||||
)
|
||||
)
|
||||
{
|
||||
fullDestinationDirectoryPath += Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(fullDestinationDirectoryPath))
|
||||
{
|
||||
throw new ExtractionException(
|
||||
$"Directory does not exist to extract to: {fullDestinationDirectoryPath}"
|
||||
);
|
||||
}
|
||||
|
||||
return fullDestinationDirectoryPath;
|
||||
}
|
||||
|
||||
internal static void EnsurePathInDestinationDirectory(
|
||||
string destinationPath,
|
||||
string fullDestinationDirectoryPath,
|
||||
string exceptionMessage
|
||||
)
|
||||
{
|
||||
if (destinationPath.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
string.Equals(
|
||||
destinationPath,
|
||||
TrimTrailingDirectorySeparators(fullDestinationDirectoryPath),
|
||||
Utility.PathComparison
|
||||
)
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ExtractionException(exceptionMessage);
|
||||
}
|
||||
|
||||
private static bool IsDirectorySeparator(char value) =>
|
||||
value == Path.DirectorySeparatorChar || value == Path.AltDirectorySeparatorChar;
|
||||
|
||||
private static string TrimTrailingDirectorySeparators(string path)
|
||||
{
|
||||
var root = Path.GetPathRoot(path);
|
||||
var rootLength = root?.Length ?? 0;
|
||||
var end = path.Length;
|
||||
|
||||
while (end > rootLength && IsDirectorySeparator(path[end - 1]))
|
||||
{
|
||||
end--;
|
||||
}
|
||||
|
||||
return end == path.Length ? path : path.Substring(0, end);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SharpCompress.Common;
|
||||
|
||||
internal static partial class ExtractionMethods
|
||||
{
|
||||
public static async ValueTask WriteEntryToDirectoryAsync(
|
||||
IEntry entry,
|
||||
string destinationDirectory,
|
||||
ExtractionOptions? options,
|
||||
Func<string, CancellationToken, ValueTask> writeAsync,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
string destinationFileName;
|
||||
var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory);
|
||||
options ??= new ExtractionOptions();
|
||||
|
||||
//check for trailing slash.
|
||||
if (
|
||||
fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1]
|
||||
!= Path.DirectorySeparatorChar
|
||||
)
|
||||
{
|
||||
fullDestinationDirectoryPath += Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(fullDestinationDirectoryPath))
|
||||
{
|
||||
throw new ExtractionException(
|
||||
$"Directory does not exist to extract to: {fullDestinationDirectoryPath}"
|
||||
);
|
||||
}
|
||||
|
||||
var file = Path.GetFileName(entry.Key.NotNull("Entry Key is null")).NotNull("File is null");
|
||||
file = Utility.ReplaceInvalidFileNameChars(file);
|
||||
if (options.ExtractFullPath)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null"))
|
||||
.NotNull("Directory is null");
|
||||
var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder));
|
||||
|
||||
if (!Directory.Exists(destdir))
|
||||
{
|
||||
if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison))
|
||||
{
|
||||
throw new ExtractionException(
|
||||
"Entry is trying to create a directory outside of the destination directory."
|
||||
);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destdir);
|
||||
}
|
||||
destinationFileName = Path.Combine(destdir, file);
|
||||
}
|
||||
else
|
||||
{
|
||||
destinationFileName = Path.Combine(fullDestinationDirectoryPath, file);
|
||||
}
|
||||
|
||||
if (!entry.IsDirectory)
|
||||
{
|
||||
destinationFileName = Path.GetFullPath(destinationFileName);
|
||||
|
||||
if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison))
|
||||
{
|
||||
throw new ExtractionException(
|
||||
"Entry is trying to write a file outside of the destination directory."
|
||||
);
|
||||
}
|
||||
await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (options.ExtractFullPath && !Directory.Exists(destinationFileName))
|
||||
{
|
||||
Directory.CreateDirectory(destinationFileName);
|
||||
}
|
||||
}
|
||||
|
||||
public static async ValueTask WriteEntryToFileAsync(
|
||||
IEntry entry,
|
||||
string destinationFileName,
|
||||
ExtractionOptions? options,
|
||||
Func<string, FileMode, CancellationToken, ValueTask> openAndWriteAsync,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
options ??= new ExtractionOptions();
|
||||
if (entry.LinkTarget != null)
|
||||
{
|
||||
if (options.SymbolicLinkHandler is not null)
|
||||
{
|
||||
options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget);
|
||||
}
|
||||
else
|
||||
{
|
||||
ExtractionOptions.DefaultSymbolicLinkHandler(destinationFileName, entry.LinkTarget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
var fm = FileMode.Create;
|
||||
|
||||
if (!options.Overwrite)
|
||||
{
|
||||
fm = FileMode.CreateNew;
|
||||
}
|
||||
|
||||
await openAndWriteAsync(destinationFileName, fm, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
entry.PreserveExtractionOptions(destinationFileName, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpCompress.Common;
|
||||
|
||||
internal static partial class ExtractionMethods
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the appropriate StringComparison for path checks based on the file system.
|
||||
/// Windows uses case-insensitive file systems, while Unix-like systems use case-sensitive file systems.
|
||||
/// </summary>
|
||||
private static StringComparison PathComparison =>
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
/// <summary>
|
||||
/// Extract to specific directory, retaining filename
|
||||
/// </summary>
|
||||
public static void WriteEntryToDirectory(
|
||||
IEntry entry,
|
||||
string destinationDirectory,
|
||||
ExtractionOptions? options,
|
||||
Action<string> write
|
||||
)
|
||||
{
|
||||
string destinationFileName;
|
||||
var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory);
|
||||
options ??= new ExtractionOptions();
|
||||
|
||||
//check for trailing slash.
|
||||
if (
|
||||
fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1]
|
||||
!= Path.DirectorySeparatorChar
|
||||
)
|
||||
{
|
||||
fullDestinationDirectoryPath += Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(fullDestinationDirectoryPath))
|
||||
{
|
||||
throw new ExtractionException(
|
||||
$"Directory does not exist to extract to: {fullDestinationDirectoryPath}"
|
||||
);
|
||||
}
|
||||
|
||||
var file = Path.GetFileName(entry.Key.NotNull("Entry Key is null")).NotNull("File is null");
|
||||
file = Utility.ReplaceInvalidFileNameChars(file);
|
||||
if (options.ExtractFullPath)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null"))
|
||||
.NotNull("Directory is null");
|
||||
var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder));
|
||||
|
||||
if (!Directory.Exists(destdir))
|
||||
{
|
||||
if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison))
|
||||
{
|
||||
throw new ExtractionException(
|
||||
"Entry is trying to create a directory outside of the destination directory."
|
||||
);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destdir);
|
||||
}
|
||||
destinationFileName = Path.Combine(destdir, file);
|
||||
}
|
||||
else
|
||||
{
|
||||
destinationFileName = Path.Combine(fullDestinationDirectoryPath, file);
|
||||
}
|
||||
|
||||
if (!entry.IsDirectory)
|
||||
{
|
||||
destinationFileName = Path.GetFullPath(destinationFileName);
|
||||
|
||||
if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison))
|
||||
{
|
||||
throw new ExtractionException(
|
||||
"Entry is trying to write a file outside of the destination directory."
|
||||
);
|
||||
}
|
||||
write(destinationFileName);
|
||||
}
|
||||
else if (options.ExtractFullPath && !Directory.Exists(destinationFileName))
|
||||
{
|
||||
Directory.CreateDirectory(destinationFileName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void WriteEntryToFile(
|
||||
IEntry entry,
|
||||
string destinationFileName,
|
||||
ExtractionOptions? options,
|
||||
Action<string, FileMode> openAndWrite
|
||||
)
|
||||
{
|
||||
options ??= new ExtractionOptions();
|
||||
if (entry.LinkTarget != null)
|
||||
{
|
||||
if (options.SymbolicLinkHandler is not null)
|
||||
{
|
||||
options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget);
|
||||
}
|
||||
else
|
||||
{
|
||||
ExtractionOptions.DefaultSymbolicLinkHandler(destinationFileName, entry.LinkTarget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
var fm = FileMode.Create;
|
||||
|
||||
if (!options.Overwrite)
|
||||
{
|
||||
fm = FileMode.CreateNew;
|
||||
}
|
||||
|
||||
openAndWrite(destinationFileName, fm);
|
||||
entry.PreserveExtractionOptions(destinationFileName, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/SharpCompress/Common/IEntryExtensions.Async.cs
Normal file
117
src/SharpCompress/Common/IEntryExtensions.Async.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SharpCompress.Common;
|
||||
|
||||
internal static partial class IEntryExtensions
|
||||
{
|
||||
extension(IEntry entry)
|
||||
{
|
||||
public async ValueTask WriteEntryToDirectoryAsync(
|
||||
string destinationDirectory,
|
||||
ExtractionOptions? options,
|
||||
Func<string, CancellationToken, ValueTask> writeAsync,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
options ??= new ExtractionOptions();
|
||||
var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(
|
||||
destinationDirectory
|
||||
);
|
||||
|
||||
await WriteEntryToDirectoryAsyncCore(
|
||||
entry,
|
||||
fullDestinationDirectoryPath,
|
||||
options,
|
||||
writeAsync,
|
||||
cancellationToken
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async ValueTask WriteEntryToDirectoryAsyncCore(
|
||||
string fullDestinationDirectoryPath,
|
||||
ExtractionOptions options,
|
||||
Func<string, CancellationToken, ValueTask>? writeAsync,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var destinationFileName = GetEntryDestinationFileName(
|
||||
entry,
|
||||
fullDestinationDirectoryPath,
|
||||
options
|
||||
);
|
||||
|
||||
if (!entry.IsDirectory)
|
||||
{
|
||||
destinationFileName = Path.GetFullPath(destinationFileName);
|
||||
|
||||
DirectoryManagement.EnsurePathInDestinationDirectory(
|
||||
destinationFileName,
|
||||
fullDestinationDirectoryPath,
|
||||
DirectoryManagement.WriteFileOutsideDestinationMessage
|
||||
);
|
||||
|
||||
if (writeAsync != null)
|
||||
{
|
||||
await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else if (options.ExtractFullPath)
|
||||
{
|
||||
destinationFileName = Path.GetFullPath(destinationFileName);
|
||||
|
||||
DirectoryManagement.EnsurePathInDestinationDirectory(
|
||||
destinationFileName,
|
||||
fullDestinationDirectoryPath,
|
||||
DirectoryManagement.CreateDirectoryOutsideDestinationMessage
|
||||
);
|
||||
|
||||
if (!Directory.Exists(destinationFileName))
|
||||
{
|
||||
Directory.CreateDirectory(destinationFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask WriteEntryToFileAsync(
|
||||
string destinationFileName,
|
||||
ExtractionOptions? options,
|
||||
Func<string, FileMode, CancellationToken, ValueTask> openAndWriteAsync,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
options ??= new ExtractionOptions();
|
||||
if (entry.LinkTarget != null)
|
||||
{
|
||||
if (options.SymbolicLinkHandler is not null)
|
||||
{
|
||||
options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget);
|
||||
}
|
||||
else
|
||||
{
|
||||
ExtractionOptions.DefaultSymbolicLinkHandler(
|
||||
destinationFileName,
|
||||
entry.LinkTarget
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
var fm = FileMode.Create;
|
||||
|
||||
if (!options.Overwrite)
|
||||
{
|
||||
fm = FileMode.CreateNew;
|
||||
}
|
||||
|
||||
await openAndWriteAsync(destinationFileName, fm, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
entry.PreserveExtractionOptions(destinationFileName, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/SharpCompress/Common/IEntryExtensions.cs
Normal file
138
src/SharpCompress/Common/IEntryExtensions.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace SharpCompress.Common;
|
||||
|
||||
internal static partial class IEntryExtensions
|
||||
{
|
||||
extension(IEntry entry)
|
||||
{
|
||||
/// <summary>
|
||||
/// Extract to specific directory, retaining filename
|
||||
/// </summary>
|
||||
public void WriteEntryToDirectory(
|
||||
string destinationDirectory,
|
||||
ExtractionOptions? options,
|
||||
Action<string> write
|
||||
)
|
||||
{
|
||||
options ??= new ExtractionOptions();
|
||||
var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(
|
||||
destinationDirectory
|
||||
);
|
||||
|
||||
WriteEntryToDirectoryCore(entry, fullDestinationDirectoryPath, options, write);
|
||||
}
|
||||
|
||||
internal void WriteEntryToDirectoryCore(
|
||||
string fullDestinationDirectoryPath,
|
||||
ExtractionOptions options,
|
||||
Action<string>? write
|
||||
)
|
||||
{
|
||||
var destinationFileName = GetEntryDestinationFileName(
|
||||
entry,
|
||||
fullDestinationDirectoryPath,
|
||||
options
|
||||
);
|
||||
|
||||
if (!entry.IsDirectory)
|
||||
{
|
||||
destinationFileName = Path.GetFullPath(destinationFileName);
|
||||
|
||||
DirectoryManagement.EnsurePathInDestinationDirectory(
|
||||
destinationFileName,
|
||||
fullDestinationDirectoryPath,
|
||||
DirectoryManagement.WriteFileOutsideDestinationMessage
|
||||
);
|
||||
write?.Invoke(destinationFileName);
|
||||
}
|
||||
else if (options.ExtractFullPath)
|
||||
{
|
||||
destinationFileName = Path.GetFullPath(destinationFileName);
|
||||
|
||||
DirectoryManagement.EnsurePathInDestinationDirectory(
|
||||
destinationFileName,
|
||||
fullDestinationDirectoryPath,
|
||||
DirectoryManagement.CreateDirectoryOutsideDestinationMessage
|
||||
);
|
||||
|
||||
if (!Directory.Exists(destinationFileName))
|
||||
{
|
||||
Directory.CreateDirectory(destinationFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetEntryDestinationFileName(
|
||||
string fullDestinationDirectoryPath,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
var file = Path.GetFileName(entry.Key.NotNull("Entry Key is null"))
|
||||
.NotNull("File is null");
|
||||
file = Utility.ReplaceInvalidFileNameChars(file);
|
||||
|
||||
if (options.ExtractFullPath)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null"))
|
||||
.NotNull("Directory is null");
|
||||
var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder));
|
||||
|
||||
DirectoryManagement.EnsurePathInDestinationDirectory(
|
||||
destdir,
|
||||
fullDestinationDirectoryPath,
|
||||
entry.IsDirectory
|
||||
? DirectoryManagement.CreateDirectoryOutsideDestinationMessage
|
||||
: DirectoryManagement.WriteFileOutsideDestinationMessage
|
||||
);
|
||||
|
||||
if (!Directory.Exists(destdir))
|
||||
{
|
||||
Directory.CreateDirectory(destdir);
|
||||
}
|
||||
|
||||
return Path.Combine(destdir, file);
|
||||
}
|
||||
|
||||
return Path.Combine(fullDestinationDirectoryPath, file);
|
||||
}
|
||||
|
||||
public void WriteEntryToFile(
|
||||
string destinationFileName,
|
||||
ExtractionOptions? options,
|
||||
Action<string, FileMode> openAndWrite
|
||||
)
|
||||
{
|
||||
options ??= new ExtractionOptions();
|
||||
if (entry.LinkTarget != null)
|
||||
{
|
||||
if (options.SymbolicLinkHandler is not null)
|
||||
{
|
||||
options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget);
|
||||
}
|
||||
else
|
||||
{
|
||||
ExtractionOptions.DefaultSymbolicLinkHandler(
|
||||
destinationFileName,
|
||||
entry.LinkTarget
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
var fm = FileMode.Create;
|
||||
|
||||
if (!options.Overwrite)
|
||||
{
|
||||
fm = FileMode.CreateNew;
|
||||
}
|
||||
|
||||
openAndWrite(destinationFileName, fm);
|
||||
entry.PreserveExtractionOptions(destinationFileName, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,8 @@ public static class IAsyncReaderExtensions
|
||||
ExtractionOptions? options = null,
|
||||
CancellationToken cancellationToken = default
|
||||
) =>
|
||||
await ExtractionMethods
|
||||
.WriteEntryToDirectoryAsync(
|
||||
reader.Entry,
|
||||
await reader
|
||||
.Entry.WriteEntryToDirectoryAsync(
|
||||
destinationDirectory,
|
||||
options,
|
||||
async (path, ct) =>
|
||||
@@ -36,14 +35,13 @@ public static class IAsyncReaderExtensions
|
||||
ExtractionOptions? options = null,
|
||||
CancellationToken cancellationToken = default
|
||||
) =>
|
||||
await ExtractionMethods
|
||||
.WriteEntryToFileAsync(
|
||||
reader.Entry,
|
||||
await reader
|
||||
.Entry.WriteEntryToFileAsync(
|
||||
destinationFileName,
|
||||
options,
|
||||
async (x, fm, ct) =>
|
||||
{
|
||||
using var fs = File.Open(destinationFileName, fm);
|
||||
using var fs = File.Open(x, fm);
|
||||
await reader.WriteEntryToAsync(fs, ct).ConfigureAwait(false);
|
||||
},
|
||||
cancellationToken
|
||||
@@ -72,14 +70,13 @@ public static class IAsyncReaderExtensions
|
||||
ExtractionOptions? options = null,
|
||||
CancellationToken cancellationToken = default
|
||||
) =>
|
||||
await ExtractionMethods
|
||||
.WriteEntryToFileAsync(
|
||||
reader.Entry,
|
||||
await reader
|
||||
.Entry.WriteEntryToFileAsync(
|
||||
destinationFileName,
|
||||
options,
|
||||
async (x, fm, ct) =>
|
||||
{
|
||||
using var fs = File.Open(destinationFileName, fm);
|
||||
using var fs = File.Open(x, fm);
|
||||
await reader.WriteEntryToAsync(fs, ct).ConfigureAwait(false);
|
||||
},
|
||||
cancellationToken
|
||||
|
||||
@@ -40,8 +40,7 @@ public static class IReaderExtensions
|
||||
string destinationDirectory,
|
||||
ExtractionOptions? options = null
|
||||
) =>
|
||||
ExtractionMethods.WriteEntryToDirectory(
|
||||
reader.Entry,
|
||||
reader.Entry.WriteEntryToDirectory(
|
||||
destinationDirectory,
|
||||
options,
|
||||
(path) => reader.WriteEntryToFile(path, options)
|
||||
@@ -54,8 +53,7 @@ public static class IReaderExtensions
|
||||
string destinationFileName,
|
||||
ExtractionOptions? options = null
|
||||
) =>
|
||||
ExtractionMethods.WriteEntryToFile(
|
||||
reader.Entry,
|
||||
reader.Entry.WriteEntryToFile(
|
||||
destinationFileName,
|
||||
options,
|
||||
(x, fm) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -13,6 +14,15 @@ namespace SharpCompress;
|
||||
|
||||
internal static partial class Utility
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the appropriate StringComparison for path checks based on the file system.
|
||||
/// Windows uses case-insensitive file systems, while Unix-like systems use case-sensitive file systems.
|
||||
/// </summary>
|
||||
internal static StringComparison PathComparison =>
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
public static bool UseSyncOverAsyncDispose()
|
||||
{
|
||||
var useSyncOverAsync = false;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#if NET8_0_OR_GREATER
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Common;
|
||||
using SharpCompress.Readers;
|
||||
using Xunit;
|
||||
using SysZip = System.IO.Compression.ZipArchive;
|
||||
using SysZipMode = System.IO.Compression.ZipArchiveMode;
|
||||
|
||||
namespace SharpCompress.Test.Security;
|
||||
|
||||
public class ExtractionPathTraversalTests : TestBase
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ReaderAll")]
|
||||
[InlineData("ReaderEntry")]
|
||||
[InlineData("Archive")]
|
||||
[InlineData("ArchiveEntry")]
|
||||
[InlineData("AsyncReaderAll")]
|
||||
[InlineData("AsyncReaderEntry")]
|
||||
[InlineData("AsyncArchive")]
|
||||
[InlineData("AsyncArchiveEntry")]
|
||||
public async Task DirectoryTraversalToExistingOutsideDirectory_ShouldThrow(string api)
|
||||
{
|
||||
var extractDir = Path.Combine(SCRATCH_FILES_PATH, "extract");
|
||||
Directory.CreateDirectory(extractDir);
|
||||
var escapedDirectory = Path.GetFullPath(Path.Combine(extractDir, "../../escaped_existing"));
|
||||
Directory.CreateDirectory(escapedDirectory);
|
||||
var archivePath = Path.Combine(SCRATCH2_FILES_PATH, $"{api}.zip");
|
||||
BuildZip(archivePath, "../../escaped_existing/");
|
||||
|
||||
var exception = await RecordExtractionExceptionAsync(api, archivePath, extractDir);
|
||||
|
||||
var extractionException = Assert.IsType<ExtractionException>(exception);
|
||||
Assert.Contains("outside of the destination", extractionException.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ReaderAll")]
|
||||
[InlineData("ReaderEntry")]
|
||||
[InlineData("Archive")]
|
||||
[InlineData("ArchiveEntry")]
|
||||
[InlineData("AsyncReaderAll")]
|
||||
[InlineData("AsyncReaderEntry")]
|
||||
[InlineData("AsyncArchive")]
|
||||
[InlineData("AsyncArchiveEntry")]
|
||||
public async Task FileTraversalToSiblingDirectory_ShouldThrow(string api)
|
||||
{
|
||||
var extractDir = Path.Combine(SCRATCH_FILES_PATH, "extract");
|
||||
Directory.CreateDirectory(extractDir);
|
||||
var siblingDirectory = Path.Combine(SCRATCH_FILES_PATH, "extract2");
|
||||
Directory.CreateDirectory(siblingDirectory);
|
||||
var archivePath = Path.Combine(SCRATCH2_FILES_PATH, $"{api}.zip");
|
||||
BuildZip(archivePath, "../extract2/evil.txt");
|
||||
|
||||
var exception = await RecordExtractionExceptionAsync(api, archivePath, extractDir);
|
||||
|
||||
var extractionException = Assert.IsType<ExtractionException>(exception);
|
||||
Assert.Contains("outside of the destination", extractionException.Message);
|
||||
Assert.False(File.Exists(Path.Combine(siblingDirectory, "evil.txt")));
|
||||
}
|
||||
|
||||
private static void BuildZip(string path, string entryName)
|
||||
{
|
||||
using var fs = File.Create(path);
|
||||
using var zip = new SysZip(fs, SysZipMode.Create);
|
||||
var entry = zip.CreateEntry(entryName);
|
||||
|
||||
if (entryName.EndsWith('/'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write("evil");
|
||||
}
|
||||
|
||||
private static async Task<Exception?> RecordExtractionExceptionAsync(
|
||||
string api,
|
||||
string archivePath,
|
||||
string extractDir
|
||||
)
|
||||
{
|
||||
var options = new ExtractionOptions { ExtractFullPath = true, Overwrite = true };
|
||||
|
||||
return api switch
|
||||
{
|
||||
"ReaderAll" => RecordException(() =>
|
||||
ExtractWithReaderAll(archivePath, extractDir, options)
|
||||
),
|
||||
"ReaderEntry" => RecordException(() =>
|
||||
ExtractWithReaderEntry(archivePath, extractDir, options)
|
||||
),
|
||||
"Archive" => RecordException(() =>
|
||||
ExtractWithArchive(archivePath, extractDir, options)
|
||||
),
|
||||
"ArchiveEntry" => RecordException(() =>
|
||||
ExtractWithArchiveEntry(archivePath, extractDir, options)
|
||||
),
|
||||
"AsyncReaderAll" => await RecordExceptionAsync(() =>
|
||||
ExtractWithAsyncReaderAllAsync(archivePath, extractDir, options)
|
||||
),
|
||||
"AsyncReaderEntry" => await RecordExceptionAsync(() =>
|
||||
ExtractWithAsyncReaderEntryAsync(archivePath, extractDir, options)
|
||||
),
|
||||
"AsyncArchive" => await RecordExceptionAsync(() =>
|
||||
ExtractWithAsyncArchiveAsync(archivePath, extractDir, options)
|
||||
),
|
||||
"AsyncArchiveEntry" => await RecordExceptionAsync(() =>
|
||||
ExtractWithAsyncArchiveEntryAsync(archivePath, extractDir, options)
|
||||
),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(api), api, null),
|
||||
};
|
||||
}
|
||||
|
||||
private static Exception? RecordException(Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Exception?> RecordExceptionAsync(Func<Task> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
await action();
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractWithReaderAll(
|
||||
string archivePath,
|
||||
string extractDir,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
using var stream = File.OpenRead(archivePath);
|
||||
using var reader = ReaderFactory.OpenReader(stream);
|
||||
reader.WriteAllToDirectory(extractDir, options);
|
||||
}
|
||||
|
||||
private static void ExtractWithReaderEntry(
|
||||
string archivePath,
|
||||
string extractDir,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
using var stream = File.OpenRead(archivePath);
|
||||
using var reader = ReaderFactory.OpenReader(stream);
|
||||
Assert.True(reader.MoveToNextEntry());
|
||||
reader.WriteEntryToDirectory(extractDir, options);
|
||||
}
|
||||
|
||||
private static void ExtractWithArchive(
|
||||
string archivePath,
|
||||
string extractDir,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
using var archive = ArchiveFactory.OpenArchive(archivePath);
|
||||
archive.WriteToDirectory(extractDir, options);
|
||||
}
|
||||
|
||||
private static void ExtractWithArchiveEntry(
|
||||
string archivePath,
|
||||
string extractDir,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
using var archive = ArchiveFactory.OpenArchive(archivePath);
|
||||
archive.Entries.Single().WriteToDirectory(extractDir, options);
|
||||
}
|
||||
|
||||
private static async Task ExtractWithAsyncReaderAllAsync(
|
||||
string archivePath,
|
||||
string extractDir,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
using var stream = File.OpenRead(archivePath);
|
||||
await using var reader = await ReaderFactory.OpenAsyncReader(stream);
|
||||
await reader.WriteAllToDirectoryAsync(extractDir, options);
|
||||
}
|
||||
|
||||
private static async Task ExtractWithAsyncReaderEntryAsync(
|
||||
string archivePath,
|
||||
string extractDir,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
using var stream = File.OpenRead(archivePath);
|
||||
await using var reader = await ReaderFactory.OpenAsyncReader(stream);
|
||||
Assert.True(await reader.MoveToNextEntryAsync());
|
||||
await reader.WriteEntryToDirectoryAsync(extractDir, options);
|
||||
}
|
||||
|
||||
private static async Task ExtractWithAsyncArchiveAsync(
|
||||
string archivePath,
|
||||
string extractDir,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
await using var archive = await ArchiveFactory.OpenAsyncArchive(archivePath);
|
||||
await archive.WriteToDirectoryAsync(extractDir, options);
|
||||
}
|
||||
|
||||
private static async Task ExtractWithAsyncArchiveEntryAsync(
|
||||
string archivePath,
|
||||
string extractDir,
|
||||
ExtractionOptions options
|
||||
)
|
||||
{
|
||||
await using var archive = await ArchiveFactory.OpenAsyncArchive(archivePath);
|
||||
|
||||
await foreach (var entry in archive.EntriesAsync)
|
||||
{
|
||||
await entry.WriteToDirectoryAsync(extractDir, options);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Archive did not contain an entry.");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
134
tests/SharpCompress.Test/Security/ZipSlip.cs
Normal file
134
tests/SharpCompress.Test/Security/ZipSlip.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
#if NET8_0_OR_GREATER
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AwesomeAssertions;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Common;
|
||||
using Xunit;
|
||||
using SysZip = System.IO.Compression.ZipArchive;
|
||||
using SysZipMode = System.IO.Compression.ZipArchiveMode;
|
||||
|
||||
namespace SharpCompress.Test.Security;
|
||||
|
||||
public class ZipSlip : TestBase
|
||||
{
|
||||
[Fact]
|
||||
public void RunSync()
|
||||
{
|
||||
Console.WriteLine("--- Sync: archive.WriteToDirectory() ---");
|
||||
var (extractDir, parentDir) = SetupDirs("sync");
|
||||
Directory.CreateDirectory(extractDir);
|
||||
var archivePath = Path.Combine(parentDir, "malicious.zip");
|
||||
|
||||
BuildMaliciousZip(archivePath);
|
||||
|
||||
using (var archive = ArchiveFactory.OpenArchive(archivePath))
|
||||
{
|
||||
var ex = Assert.Throws<ExtractionException>(() =>
|
||||
archive.WriteToDirectory(
|
||||
extractDir,
|
||||
new ExtractionOptions { ExtractFullPath = true }
|
||||
)
|
||||
);
|
||||
ex.Message.Should()
|
||||
.Contain(
|
||||
"Entry is trying to create a directory outside of the destination directory"
|
||||
);
|
||||
}
|
||||
|
||||
CheckResults(archivePath, parentDir, extractDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync()
|
||||
{
|
||||
Console.WriteLine("--- Async: archive.WriteToDirectoryAsync() ---");
|
||||
var (extractDir, parentDir) = SetupDirs("async");
|
||||
Directory.CreateDirectory(extractDir);
|
||||
var archivePath = Path.Combine(parentDir, "malicious.zip");
|
||||
|
||||
BuildMaliciousZip(archivePath);
|
||||
|
||||
var archive = await ArchiveFactory.OpenAsyncArchive(archivePath);
|
||||
await using (archive)
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<ExtractionException>(async () =>
|
||||
await archive.WriteToDirectoryAsync(
|
||||
extractDir,
|
||||
new ExtractionOptions { ExtractFullPath = true }
|
||||
)
|
||||
);
|
||||
ex.Message.Should()
|
||||
.Contain(
|
||||
"Entry is trying to create a directory outside of the destination directory"
|
||||
);
|
||||
}
|
||||
|
||||
CheckResults(archivePath, parentDir, extractDir);
|
||||
}
|
||||
|
||||
// Craft a ZIP with malicious directory entries using System.IO.Compression
|
||||
// so we bypass any SharpCompress write-side normalisation.
|
||||
static void BuildMaliciousZip(string path)
|
||||
{
|
||||
using var fs = File.Create(path);
|
||||
using var zip = new SysZip(fs, SysZipMode.Create);
|
||||
|
||||
// 1. Relative traversal: two levels up, then "escaped_relative/"
|
||||
zip.CreateEntry("../../escaped_relative/");
|
||||
|
||||
// 2. Absolute Unix path (Path.Combine discards the base when second arg is rooted)
|
||||
zip.CreateEntry("/tmp/escaped_absolute/");
|
||||
|
||||
// 3. A legitimate entry for contrast
|
||||
zip.CreateEntry("safe_subdir/");
|
||||
}
|
||||
|
||||
private (string extractDir, string parentDir) SetupDirs(string label)
|
||||
{
|
||||
var parentDir = Path.Combine(
|
||||
SCRATCH_FILES_PATH,
|
||||
$"sc_poc_{label}_{Path.GetRandomFileName()}"
|
||||
);
|
||||
Directory.CreateDirectory(parentDir);
|
||||
var extractDir = Path.Combine(parentDir, "extract_target");
|
||||
|
||||
Console.WriteLine($" Parent : {parentDir}");
|
||||
Console.WriteLine($" Target : {extractDir}");
|
||||
return (extractDir, parentDir);
|
||||
}
|
||||
|
||||
static void CheckResults(string archivePath, string parentDir, string extractDir)
|
||||
{
|
||||
Console.WriteLine(" Directories created after extraction:");
|
||||
foreach (var d in Directory.GetDirectories(parentDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(parentDir, d);
|
||||
var escaped = !d.StartsWith(extractDir, StringComparison.Ordinal);
|
||||
Console.WriteLine($" {(escaped ? "[ESCAPED]" : "[ ok ]")} {relative}");
|
||||
}
|
||||
|
||||
// Relative traversal "../../escaped_relative/" escapes two levels above extractDir
|
||||
// (which is parentDir/extract_target), landing in Path.GetTempPath()
|
||||
var relTarget = Path.GetFullPath(Path.Combine(extractDir, "../../escaped_relative"));
|
||||
if (Directory.Exists(relTarget))
|
||||
{
|
||||
Console.WriteLine($" [ESCAPED] relative traversal created: {relTarget}");
|
||||
Directory.Delete(relTarget);
|
||||
}
|
||||
File.Delete(archivePath);
|
||||
if (Directory.Exists(extractDir))
|
||||
{
|
||||
Directory.Delete(extractDir);
|
||||
}
|
||||
|
||||
var absTarget = "/tmp/escaped_absolute";
|
||||
if (Directory.Exists(absTarget))
|
||||
{
|
||||
Console.WriteLine($" [ESCAPED] absolute path created: {absTarget}");
|
||||
Directory.Delete(absTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user