From 1b51d207c4d322eeb3b6f0cf21e4bb70ea2e845b Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:02:22 +0000 Subject: [PATCH] fix: sanitize ZIP entry names to prevent path traversal CreateZip used the caller-supplied file name verbatim as the ZIP entry name, allowing traversal sequences (e.g. "../../") to be embedded in produced archives. Sanitize each name with Path.GetFileName so only the bare file component is stored, and throw ArgumentException when the name has no valid file component. Refs GHSA-mr97-hxhp-w3gg (encryption4all/postguard-dotnet#40) Co-Authored-By: Claude Opus 4.8 --- src/Zip/ZipHelper.cs | 12 ++++++++- tests/E4A.PostGuard.Tests/ZipHelperTests.cs | 27 ++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Zip/ZipHelper.cs b/src/Zip/ZipHelper.cs index 974260f..a683bb9 100644 --- a/src/Zip/ZipHelper.cs +++ b/src/Zip/ZipHelper.cs @@ -12,7 +12,17 @@ public static byte[] CreateZip(IReadOnlyList files) { foreach (var file in files) { - var entry = archive.CreateEntry(file.Name, CompressionLevel.Optimal); + // Strip any directory components so traversal sequences (e.g. "../../") + // cannot be embedded as ZIP entry names in produced archives. + var entryName = Path.GetFileName(file.Name); + if (string.IsNullOrEmpty(entryName)) + { + throw new ArgumentException( + $"File name '{file.Name}' does not resolve to a valid entry name.", + nameof(files)); + } + + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); using var entryStream = entry.Open(); file.Content.CopyTo(entryStream); } diff --git a/tests/E4A.PostGuard.Tests/ZipHelperTests.cs b/tests/E4A.PostGuard.Tests/ZipHelperTests.cs index fa8507c..5f16746 100644 --- a/tests/E4A.PostGuard.Tests/ZipHelperTests.cs +++ b/tests/E4A.PostGuard.Tests/ZipHelperTests.cs @@ -44,10 +44,35 @@ public void MultipleFiles_AllPresentWithContent() var entries = ReadZip(zip); Assert.Equal(3, entries.Count); Assert.Equal("alpha", entries["a.txt"]); - Assert.Equal("beta", entries["dir/b.txt"]); + // Directory components are stripped, so "dir/b.txt" is stored as "b.txt". + Assert.Equal("beta", entries["b.txt"]); Assert.Equal("gamma", entries["c.bin"]); } + [Theory] + [InlineData("../../etc/passwd", "passwd")] + [InlineData("dir/nested/file.txt", "file.txt")] + [InlineData("/absolute/path.txt", "path.txt")] + public void SanitizesEntryNames_StrippingDirectoryComponents(string name, string expected) + { + var zip = ZipHelper.CreateZip([File(name, "payload")]); + + var entries = ReadZip(zip); + var entryName = Assert.Single(entries.Keys); + Assert.Equal(expected, entryName); + Assert.DoesNotContain("..", entryName); + Assert.Equal("payload", entries[entryName]); + } + + [Theory] + [InlineData("")] + [InlineData("dir/")] + [InlineData("../")] + public void ThrowsArgumentException_WhenNameHasNoFileComponent(string name) + { + Assert.Throws(() => ZipHelper.CreateZip([File(name, "payload")])); + } + [Fact] public void EmptyFileList_ProducesValidEmptyZip() {