Skip to content

Commit 1b51d20

Browse files
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 (#40) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 73c2e43 commit 1b51d20

2 files changed

Lines changed: 37 additions & 2 deletions

File tree

src/Zip/ZipHelper.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,17 @@ public static byte[] CreateZip(IReadOnlyList<PgFile> files)
1212
{
1313
foreach (var file in files)
1414
{
15-
var entry = archive.CreateEntry(file.Name, CompressionLevel.Optimal);
15+
// Strip any directory components so traversal sequences (e.g. "../../")
16+
// cannot be embedded as ZIP entry names in produced archives.
17+
var entryName = Path.GetFileName(file.Name);
18+
if (string.IsNullOrEmpty(entryName))
19+
{
20+
throw new ArgumentException(
21+
$"File name '{file.Name}' does not resolve to a valid entry name.",
22+
nameof(files));
23+
}
24+
25+
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
1626
using var entryStream = entry.Open();
1727
file.Content.CopyTo(entryStream);
1828
}

tests/E4A.PostGuard.Tests/ZipHelperTests.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,35 @@ public void MultipleFiles_AllPresentWithContent()
4444
var entries = ReadZip(zip);
4545
Assert.Equal(3, entries.Count);
4646
Assert.Equal("alpha", entries["a.txt"]);
47-
Assert.Equal("beta", entries["dir/b.txt"]);
47+
// Directory components are stripped, so "dir/b.txt" is stored as "b.txt".
48+
Assert.Equal("beta", entries["b.txt"]);
4849
Assert.Equal("gamma", entries["c.bin"]);
4950
}
5051

52+
[Theory]
53+
[InlineData("../../etc/passwd", "passwd")]
54+
[InlineData("dir/nested/file.txt", "file.txt")]
55+
[InlineData("/absolute/path.txt", "path.txt")]
56+
public void SanitizesEntryNames_StrippingDirectoryComponents(string name, string expected)
57+
{
58+
var zip = ZipHelper.CreateZip([File(name, "payload")]);
59+
60+
var entries = ReadZip(zip);
61+
var entryName = Assert.Single(entries.Keys);
62+
Assert.Equal(expected, entryName);
63+
Assert.DoesNotContain("..", entryName);
64+
Assert.Equal("payload", entries[entryName]);
65+
}
66+
67+
[Theory]
68+
[InlineData("")]
69+
[InlineData("dir/")]
70+
[InlineData("../")]
71+
public void ThrowsArgumentException_WhenNameHasNoFileComponent(string name)
72+
{
73+
Assert.Throws<ArgumentException>(() => ZipHelper.CreateZip([File(name, "payload")]));
74+
}
75+
5176
[Fact]
5277
public void EmptyFileList_ProducesValidEmptyZip()
5378
{

0 commit comments

Comments
 (0)