Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Formats.Tar;
using System.IO.Compression;
// <SafeExtractEntry>
void SafeExtractEntry(ZipArchiveEntry entry, string destinationPath, long maxDecompressedSize)

Check warning on line 4 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'SafeExtractEntry' is declared but never used

Check warning on line 4 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'SafeExtractEntry' is declared but never used
{
// The runtime enforces that entry.Open() will never produce more than
// entry.Length bytes, so checking the declared size is sufficient.
Expand All @@ -16,7 +16,7 @@
// </SafeExtractEntry>

// <SafeExtractArchive>
void SafeExtractArchive(ZipArchive archive, string destinationDir,

Check warning on line 19 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'SafeExtractArchive' is declared but never used
long maxTotalSize, int maxEntryCount)
{
// Flat zip bombs can contain many entries that each expand to large sizes.
Expand All @@ -41,7 +41,7 @@
// </SafeExtractArchive>

// <PathValidation>
void ValidatePaths(ZipArchive archive, string destinationDir)

Check warning on line 44 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'ValidatePaths' is declared but never used
{
string fullDestDir = Path.GetFullPath(destinationDir);
if (!fullDestDir.EndsWith(Path.DirectorySeparatorChar))
Expand All @@ -59,7 +59,7 @@
// </PathValidation>

// <VulnerablePattern>
void DangerousExtract(string extractDir)

Check warning on line 62 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'DangerousExtract' is declared but never used
{
// ⚠️ DANGEROUS: entry.FullName could contain "../" sequences
using ZipArchive archive = ZipFile.OpenRead("archive.zip");
Expand All @@ -71,8 +71,15 @@
}
// </VulnerablePattern>

bool ValidateName(string name)
{
// Placeholder for a policy that checks for allowed characters, reserved names, etc.
// For example, you might disallow names with invalid characters or reserved device names.
return !string.IsNullOrWhiteSpace(name) && !name.Contains("..");
}
Comment thread
rzikm marked this conversation as resolved.

// <SafeExtractZip>
void SafeExtractZip(string archivePath, string destinationDir,

Check warning on line 82 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'SafeExtractZip' is declared but never used
long maxTotalSize, long maxEntrySize, int maxEntryCount)
{
// Resolve the destination to an absolute path and ensure it ends with a
Expand Down Expand Up @@ -103,6 +110,19 @@
if (totalSize > maxTotalSize)
throw new InvalidOperationException("Archive exceeds total size limit.");

// The entry Name can contain arbitrary characters. Some characters might not be
// allowed on certain filesystems or have a special meaning. Applications should
// apply their own policies regarding allowed filenames. ValidateName is a placeholder
// for such a policy.
if (!ValidateName(entry.FullName))
{
throw new IOException($"Entry name '{entry.FullName}' is not allowed.");
}

// ExternalAttributes carry permission bits for Unix platforms. Clearing the
// attributes enforces extraction with default file permissions.
entry.ExternalAttributes = 0;

// Resolve the full destination path using Path.GetFullPath, which
// normalizes away any "../" segments. Then verify the result still
// starts with the destination directory.
Expand All @@ -129,7 +149,7 @@
// </SafeExtractZip>

// <SafeExtractTar>
void SafeExtractTar(Stream archiveStream, string destinationDir,

Check warning on line 152 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'SafeExtractTar' is declared but never used
long maxTotalSize, long maxEntrySize, int maxEntryCount)
{
// Same trailing-separator technique as the ZIP example.
Expand Down Expand Up @@ -174,6 +194,35 @@
if (!allowedTypes.Contains(entry.EntryType))
continue;

// The entry Name can contain arbitrary characters. Some characters might not be
// allowed on certain filesystems or have a special meaning. Applications should
// apply their own policies regarding allowed filenames. ValidateName is a placeholder
// for such a policy.
if (!ValidateName(entry.Name))
{
throw new IOException($"Entry name '{entry.Name}' is not allowed.");
}

// Mask the entry's permission bits to a safe subset.
// This subset depends on your application's needs.
const UnixFileMode PermittedFileModes =
UnixFileMode.UserRead | UnixFileMode.UserWrite |
UnixFileMode.GroupRead |
UnixFileMode.OtherRead;

const UnixFileMode PermittedDirectoryModes =
PermittedFileModes |
UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;

if (entry.EntryType == TarEntryType.Directory)
{
entry.Mode &= PermittedDirectoryModes;
}
else
{
entry.Mode &= PermittedFileModes;
}

// Normalize and validate the path, same as the ZIP example.
string destPath = Path.GetFullPath(Path.Join(fullDestDir, entry.Name));
if (!destPath.StartsWith(fullDestDir, StringComparison.Ordinal))
Expand All @@ -195,7 +244,7 @@
// </SafeExtractTar>

// <ValidateSymlink>
bool IsLinkTargetSafe(TarEntry entry, string fullDestDir)

Check warning on line 247 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'IsLinkTargetSafe' is declared but never used
{
// A symlink with an absolute (rooted) target is resolved from the filesystem root, not from the extraction directory.
if (Path.IsPathRooted(entry.LinkName))
Expand Down Expand Up @@ -224,7 +273,7 @@
// </ValidateSymlink>

// <StreamingApproach>
void StreamingModify()

Check warning on line 276 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'StreamingModify' is declared but never used
{
// ✅ Streaming approach for large archives
using var input = new ZipArchive(File.OpenRead("large.zip"), ZipArchiveMode.Read);
Expand All @@ -246,7 +295,7 @@
// </StreamingApproach>

// <TarStreaming>
void TarStreamingRead(Stream archiveStream, string destDir)

Check warning on line 298 in docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs

View workflow job for this annotation

GitHub Actions / snippets-build

The local function 'TarStreamingRead' is declared but never used
{
using var reader = new TarReader(archiveStream);
TarEntry? entry;
Expand Down
51 changes: 50 additions & 1 deletion docs/standard/io/zip-tar-best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,10 @@ For untrusted input—user uploads, third-party downloads, or network transfers

- [What the convenience methods don't protect you from](#what-the-convenience-methods-dont-protect-you-from)
- [Enforce size and entry count limits](#enforce-size-and-entry-count-limits)
- [Validate file names](#validate-file-names)
- [Validate destination paths](#validate-destination-paths)
- [Handle symbolic and hard links (TAR)](#handle-symbolic-and-hard-links-tar)
- [Entry permission bits (Unix only)](#entry-permission-bits-unix-only)
- [Complete safe extraction examples](#complete-safe-extraction-examples)

### What the convenience methods don't protect you from
Expand Down Expand Up @@ -115,6 +117,10 @@ Neither <xref:System.IO.Compression.ZipArchive> nor <xref:System.Formats.Tar.Tar
> [!TIP]
> The same approach applies to TAR archives. Since TAR files are read entry-by-entry via <xref:System.Formats.Tar.TarReader.GetNextEntry*>, track both the cumulative data size and entry count as you iterate.

### Validate file names

Depending on the filesystem, some characters might not be allowed in filenames (or allowed only in certain positions), or have special meaning. Applications should check that the extracted entry names conform to an acceptable pattern.

### Validate destination paths

When you use the streaming APIs, you're responsible for validating every entry's destination path. They perform no path validation at all.
Expand Down Expand Up @@ -162,6 +168,10 @@ If your use case requires extracting archives with hard links but you want to av

For reference, <xref:System.Formats.Tar.TarFile.ExtractToDirectory*?displayProperty=nameWithType> validates both the entry path and link target path against the destination directory boundary. If either resolves outside, an <xref:System.IO.IOException> is thrown. <xref:System.Formats.Tar.TarEntry.ExtractToFile*?displayProperty=nameWithType> rejects symbolic and hard link entries entirely—it throws <xref:System.InvalidOperationException>.

### Entry permission bits (Unix only)

On Unix-like systems, the convenience APIs apply permission bits from the archive metadata to the extracted file or directory. These permissions might be too broad for the application scenario. In your application, you might want to prevent extraction of files that have executable permissions set. For more information, see the [Unix file permissions](#unix-file-permissions) section of this article.

### Complete safe extraction examples

Combine path traversal validation, size limits, entry count limits, and link handling in a single extraction loop.
Expand Down Expand Up @@ -219,7 +229,44 @@ Archive behavior can vary between Windows and Unix. Keep these differences in mi
- **ZIP:** Unix permissions are stored in the upper 16 bits of <xref:System.IO.Compression.ZipArchiveEntry.ExternalAttributes?displayProperty=nameWithType>. When extracting on Unix via `ExtractToDirectory` or `ExtractToFile`, the runtime restores ownership permissions (read/write/execute for user/group/other), subject to the process umask. SetUID, SetGID, and StickyBit are stripped. Permissions are not applied if the upper bits are zero. This happens when the ZIP was created on Windows, because .NET on Windows sets `DefaultFileExternalAttributes` to `0`. On Windows, these attributes are always ignored during extraction.
- **TAR:** The <xref:System.Formats.Tar.TarEntry.Mode?displayProperty=nameWithType> property represents `UnixFileMode` and can store all 12 permission bits (read/write/execute for user/group/other, plus SetUID, SetGID, and StickyBit). When extracting on Unix via `ExtractToDirectory` or `ExtractToFile`, the runtime applies only the 9 ownership bits (rwx for user/group/other), subject to the process umask. SetUID, SetGID, and StickyBit are stripped for security.

When processing untrusted archives, be aware that extracted files may have executable permissions set by the archive author. Untrusted archives could contain malicious executable files.
When processing untrusted archives, be aware that extracted files might have executable permissions set by the archive author. Untrusted archives could contain malicious executable files. Since <xref:System.IO.Compression.ZipArchiveEntry.ExternalAttributes?displayProperty=nameWithType> and <xref:System.Formats.Tar.TarEntry.Mode?displayProperty=nameWithType> are writable, you can modify them before extraction:

```csharp
foreach (ZipArchiveEntry entry in archive.Entries)
{
// Unset the external attributes to force extraction with default Unix permission bits.
entry.ExternalAttributes = 0;

// .. other validation omitted for brevity

Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
entry.ExtractToFile(destPath, overwrite: false);
}
```

```csharp

const UnixFileMode PermittedFileModes =
UnixFileMode.UserRead | UnixFileMode.UserWrite |
UnixFileMode.GroupRead |
UnixFileMode.OtherRead;

const UnixFileMode PermittedDirectoryModes =
PermittedFileModes |
UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;

while ((entry = reader.GetNextEntry()) is not null)
{
if (entry.EntryType == TarEntryType.Directory)
{
entry.Mode &= PermittedDirectoryModes;
}
else
{
entry.Mode &= PermittedFileModes;
}
}
```

### Special entry types (TAR)

Expand Down Expand Up @@ -333,8 +380,10 @@ Before deploying code that handles archives from untrusted sources, verify you'v

- **Manual iteration:** Don't use `ExtractToDirectory` for untrusted input—iterate entries manually to enforce all limits.
- **Path traversal:** Validate all destination paths with `Path.GetFullPath()` + `StartsWith()`.
- **Special characters in names:** Validate that entry names conform to the application-defined policy.
- **Decompression bombs:** Enforce limits on decompressed size (per-entry and total) and entry count.
- **Symlink/hardlink attacks (TAR):** Validate link targets resolve within the destination, or skip link entries entirely.
- **Unix permissions:** Validate each entry's permission bits. Skip entries with too broad permissions or mask/modify the unwanted permission bits before extraction.
- **Memory limits:** Avoid <xref:System.IO.Compression.ZipArchiveMode.Update?displayProperty=nameWithType> for large untrusted archives. Avoid <xref:System.IO.Compression.ZipArchiveMode.Read?displayProperty=nameWithType> mode with unseekable streams from untrusted sources.
- **Thread safety:** Don't share <xref:System.IO.Compression.ZipArchive>, <xref:System.Formats.Tar.TarReader?displayProperty=fullName>, or <xref:System.Formats.Tar.TarWriter?displayProperty=fullName> instances across threads.
- **Untrusted metadata:** Treat entry names, comments, and extra fields as untrusted input. Sanitize before display or processing.
Expand Down
Loading