Skip to content

Commit daad1e6

Browse files
authored
Merge pull request #1406 from adamhathcock/adam/fix-tar-symlink
fixes SharpCompress TAR extraction overwrites files outside the extra…ction root when a SymbolicLinkHandler enables symlink chaining
2 parents abe1c3e + 12e239e commit daad1e6

7 files changed

Lines changed: 512 additions & 12 deletions

File tree

docs/API.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,8 @@ using (var archive = ZipArchive.OpenArchive("file.zip"))
463463

464464
`CheckCrc` validates archive-level payload checksums when the format stores reliable metadata, such as ZIP CRC32 values. Formats without payload checksums skip this validation. Decompressor integrity checks that are required to decode a stream may still fail even when `CheckCrc` is disabled.
465465

466+
When using `SymbolicLinkHandler`, directory extraction rejects link targets outside the extraction root and never follows symbolic links or reparse points while extracting later entries. The handler itself remains trusted application code.
467+
466468
### Options matrix
467469

468470
```text

src/SharpCompress/Common/DirectoryManagement.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.IO;
23

34
namespace SharpCompress.Common;
@@ -8,6 +9,10 @@ internal static class DirectoryManagement
89
"Entry is trying to create a directory outside of the destination directory.";
910
internal const string WriteFileOutsideDestinationMessage =
1011
"Entry is trying to write a file outside of the destination directory.";
12+
internal const string LinkTargetOutsideDestinationMessage =
13+
"Entry is trying to create a symbolic link whose target is outside of the destination directory.";
14+
internal const string ReparsePointInDestinationMessage =
15+
"Entry is trying to extract through a symbolic link or reparse point.";
1116

1217
internal static string GetFullDestinationDirectoryPath(string destinationDirectory)
1318
{
@@ -58,6 +63,119 @@ string exceptionMessage
5863
throw new ExtractionException(exceptionMessage);
5964
}
6065

66+
internal static void EnsureNoReparsePointInDestinationDirectory(
67+
string destinationPath,
68+
string fullDestinationDirectoryPath
69+
)
70+
{
71+
var destinationDirectoryPath = TrimTrailingDirectorySeparators(
72+
fullDestinationDirectoryPath
73+
);
74+
EnsurePathIsNotReparsePoint(destinationDirectoryPath);
75+
76+
if (string.Equals(destinationPath, destinationDirectoryPath, Utility.PathComparison))
77+
{
78+
return;
79+
}
80+
81+
var relativeDestinationPath = destinationPath.Substring(
82+
fullDestinationDirectoryPath.Length
83+
);
84+
var path = destinationDirectoryPath;
85+
86+
foreach (
87+
var pathPart in relativeDestinationPath.Split(
88+
new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
89+
StringSplitOptions.RemoveEmptyEntries
90+
)
91+
)
92+
{
93+
path = Path.Combine(path, pathPart);
94+
95+
if (!PathExistsAndIsNotReparsePoint(path))
96+
{
97+
return;
98+
}
99+
}
100+
}
101+
102+
internal static void CreateDirectory(
103+
string destinationPath,
104+
string fullDestinationDirectoryPath
105+
)
106+
{
107+
EnsureNoReparsePointInDestinationDirectory(destinationPath, fullDestinationDirectoryPath);
108+
109+
if (!Directory.Exists(destinationPath))
110+
{
111+
Directory.CreateDirectory(destinationPath);
112+
}
113+
114+
EnsureNoReparsePointInDestinationDirectory(destinationPath, fullDestinationDirectoryPath);
115+
}
116+
117+
internal static void EnsureLinkTargetInDestinationDirectory(
118+
string destinationFileName,
119+
string linkTarget,
120+
string fullDestinationDirectoryPath
121+
)
122+
{
123+
var destinationDirectory = Path.GetDirectoryName(destinationFileName)
124+
.NotNull("Destination directory is null");
125+
var fullLinkTargetPath = Path.GetFullPath(Path.Combine(destinationDirectory, linkTarget));
126+
127+
EnsurePathInDestinationDirectory(
128+
fullLinkTargetPath,
129+
fullDestinationDirectoryPath,
130+
LinkTargetOutsideDestinationMessage
131+
);
132+
}
133+
134+
internal static void EnsurePathIsNotReparsePoint(string path)
135+
{
136+
var destinationDirectory = Path.GetDirectoryName(path);
137+
if (destinationDirectory is not null)
138+
{
139+
PathExistsAndIsNotReparsePoint(destinationDirectory);
140+
}
141+
PathExistsAndIsNotReparsePoint(path);
142+
}
143+
144+
private static bool PathExistsAndIsNotReparsePoint(string path)
145+
{
146+
try
147+
{
148+
if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
149+
{
150+
throw new ExtractionException(ReparsePointInDestinationMessage);
151+
}
152+
153+
return true;
154+
}
155+
catch (FileNotFoundException)
156+
{
157+
return false;
158+
}
159+
catch (DirectoryNotFoundException)
160+
{
161+
return false;
162+
}
163+
catch (UnauthorizedAccessException exception)
164+
{
165+
throw new ExtractionException(
166+
"Unable to verify the extraction path for symbolic links or reparse points.",
167+
exception
168+
);
169+
}
170+
catch (IOException exception)
171+
{
172+
throw new ExtractionException(
173+
"Unable to verify the extraction path for symbolic links or reparse points.",
174+
exception
175+
);
176+
}
177+
}
178+
61179
private static bool IsDirectorySeparator(char value) =>
62180
value == Path.DirectorySeparatorChar || value == Path.AltDirectorySeparatorChar;
63181

src/SharpCompress/Common/ExtractionOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ public sealed record ExtractionOptions : IExtractionOptions
6060
/// <remarks>
6161
/// <b>Breaking change:</b> Changed from field to property in version 0.40.0.
6262
/// If no handler is provided, symbolic links are silently skipped during extraction.
63+
/// Directory extraction rejects link targets outside the destination directory and does not
64+
/// follow symbolic links or reparse points in later entry paths.
6365
/// </remarks>
6466
public Action<string, string>? SymbolicLinkHandler { get; set; }
6567

src/SharpCompress/Common/IEntryExtensions.Async.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ internal async ValueTask WriteEntryToDirectoryAsyncCore(
3838
CancellationToken cancellationToken = default
3939
)
4040
{
41+
if (entry.LinkTarget is not null && options.SymbolicLinkHandler is null)
42+
{
43+
return;
44+
}
45+
4146
var destinationFileName = GetEntryDestinationFileName(
4247
entry,
4348
fullDestinationDirectoryPath,
@@ -54,6 +59,20 @@ internal async ValueTask WriteEntryToDirectoryAsyncCore(
5459
DirectoryManagement.WriteFileOutsideDestinationMessage
5560
);
5661

62+
DirectoryManagement.EnsureNoReparsePointInDestinationDirectory(
63+
destinationFileName,
64+
fullDestinationDirectoryPath
65+
);
66+
67+
if (entry.LinkTarget is not null)
68+
{
69+
DirectoryManagement.EnsureLinkTargetInDestinationDirectory(
70+
destinationFileName,
71+
entry.LinkTarget,
72+
fullDestinationDirectoryPath
73+
);
74+
}
75+
5776
if (writeAsync != null)
5877
{
5978
await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false);
@@ -69,10 +88,10 @@ internal async ValueTask WriteEntryToDirectoryAsyncCore(
6988
DirectoryManagement.CreateDirectoryOutsideDestinationMessage
7089
);
7190

72-
if (!Directory.Exists(destinationFileName))
73-
{
74-
Directory.CreateDirectory(destinationFileName);
75-
}
91+
DirectoryManagement.CreateDirectory(
92+
destinationFileName,
93+
fullDestinationDirectoryPath
94+
);
7695
}
7796
}
7897

@@ -90,6 +109,8 @@ public async ValueTask WriteEntryToFileAsync(
90109
}
91110
else
92111
{
112+
DirectoryManagement.EnsurePathIsNotReparsePoint(destinationFileName);
113+
93114
var fm = FileMode.Create;
94115

95116
if (!options.Overwrite)

src/SharpCompress/Common/IEntryExtensions.cs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ internal void WriteEntryToDirectoryCore(
4545
Action<string>? write
4646
)
4747
{
48+
if (entry.LinkTarget is not null && options.SymbolicLinkHandler is null)
49+
{
50+
return;
51+
}
52+
4853
var destinationFileName = GetEntryDestinationFileName(
4954
entry,
5055
fullDestinationDirectoryPath,
@@ -60,6 +65,21 @@ internal void WriteEntryToDirectoryCore(
6065
fullDestinationDirectoryPath,
6166
DirectoryManagement.WriteFileOutsideDestinationMessage
6267
);
68+
69+
DirectoryManagement.EnsureNoReparsePointInDestinationDirectory(
70+
destinationFileName,
71+
fullDestinationDirectoryPath
72+
);
73+
74+
if (entry.LinkTarget is not null)
75+
{
76+
DirectoryManagement.EnsureLinkTargetInDestinationDirectory(
77+
destinationFileName,
78+
entry.LinkTarget,
79+
fullDestinationDirectoryPath
80+
);
81+
}
82+
6383
write?.Invoke(destinationFileName);
6484
}
6585
else if (options.ExtractFullPath)
@@ -72,10 +92,10 @@ internal void WriteEntryToDirectoryCore(
7292
DirectoryManagement.CreateDirectoryOutsideDestinationMessage
7393
);
7494

75-
if (!Directory.Exists(destinationFileName))
76-
{
77-
Directory.CreateDirectory(destinationFileName);
78-
}
95+
DirectoryManagement.CreateDirectory(
96+
destinationFileName,
97+
fullDestinationDirectoryPath
98+
);
7999
}
80100
}
81101

@@ -102,10 +122,7 @@ ExtractionOptions options
102122
: DirectoryManagement.WriteFileOutsideDestinationMessage
103123
);
104124

105-
if (!Directory.Exists(destdir))
106-
{
107-
Directory.CreateDirectory(destdir);
108-
}
125+
DirectoryManagement.CreateDirectory(destdir, fullDestinationDirectoryPath);
109126

110127
return Path.Combine(destdir, file);
111128
}
@@ -126,6 +143,8 @@ Action<string, FileMode> openAndWrite
126143
}
127144
else
128145
{
146+
DirectoryManagement.EnsurePathIsNotReparsePoint(destinationFileName);
147+
129148
var fm = FileMode.Create;
130149

131150
if (!options.Overwrite)

src/SharpCompress/Common/Options/IExtractionOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public interface IExtractionOptions
3939
/// Delegate for writing symbolic links to disk.
4040
/// The first parameter is the source path (where the symlink is created).
4141
/// The second parameter is the target path (what the symlink refers to).
42+
/// Directory extraction rejects link targets outside the destination directory and does not
43+
/// follow symbolic links or reparse points in later entry paths.
4244
/// </summary>
4345
Action<string, string>? SymbolicLinkHandler { get; set; }
4446
}

0 commit comments

Comments
 (0)