From 677b7fa0430f915ebc4f224303aad4f7812eadb5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 10:16:21 +0900 Subject: [PATCH 1/4] Cap package normalization ZIP resources (#2892) --- DEVELOPER_GUIDE.md | 8 + changelog.d/unreleased/2892.security.md | 18 +++ tests/CodeIndex.Tests/ReleaseWorkflowTests.cs | 120 +++++++++++++++ .../CodeIndex.PackageNormalize.csproj | 4 + .../PackageNormalizeCli.cs | 142 ++++++++++++++++-- 5 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/2892.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7ef4a341c2..304d7b0688 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -160,6 +160,10 @@ part name on each pack run; the normalizer rewrites that part to matching content-type and relationship references, and gives ZIP entries stable timestamps. This is the package reproducibility boundary for `.nupkg` and `.snupkg` archives (#2756). +Before rewriting, the normalizer rejects packages with more than 4096 ZIP +entries, any entry above 128 MiB uncompressed, total uncompressed content above +512 MiB, or XML reference text above 16 MiB so crafted packages cannot force +unbounded normalization work (#2892). When you intentionally update a dependency (or add a new direct `PackageReference`), regenerate the lock files locally and commit the diff in the same change: @@ -2048,6 +2052,10 @@ release の `dotnet publish`(RID ごと)と `dotnet pack`(NuGet パッケ `package/services/metadata/core-properties/core-properties.psmdcp` に書き換え、 対応する content-type / relationship 参照も更新し、ZIP entry timestamp を固定します。 これが `.nupkg` / `.snupkg` archive の package 再現性境界です (#2756)。 +書き換え前に、normalizer は 4096 を超える ZIP entry、128 MiB を超える +uncompressed entry、512 MiB を超える合計 uncompressed content、または +16 MiB を超える XML 参照テキストを持つ package を拒否し、細工された +package が無制限の normalize 作業を強制できないようにします (#2892)。 依存を意図的に更新する(あるいは直接 `PackageReference` を追加する)場合は、ローカルで lock ファイルを再生成し、同じ変更でコミットしてください: diff --git a/changelog.d/unreleased/2892.security.md b/changelog.d/unreleased/2892.security.md new file mode 100644 index 0000000000..85a9db9859 --- /dev/null +++ b/changelog.d/unreleased/2892.security.md @@ -0,0 +1,18 @@ +--- +category: security +issues: + - 2892 +affected: + - tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj + - tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Package normalization now caps ZIP resource usage (#2892)** — `CodeIndex.PackageNormalize` now rejects packages that exceed documented ZIP entry-count, per-entry, total-uncompressed, or XML-text limits before rewriting release artifacts. + +## 日本語 + +- **Package normalize が ZIP resource 使用量を上限で制限するようになりました (#2892)** — `CodeIndex.PackageNormalize` は release artifact を書き換える前に、文書化された ZIP entry 数、entry 単位、合計 uncompressed size、XML text の上限を超える package を拒否します。 diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index 294fd4a554..c95bf5e316 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -146,6 +146,119 @@ public void PackageNormalizer_RewritesRandomCorePropertiesPartDeterministically( } } + [Fact] + public void PackageNormalizer_RejectsPackageThatExceedsEntryCountLimit() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsPackageThatExceedsEntryCountLimit)); + try + { + var packagePath = Path.Combine(projectRoot, "too-many-entries.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("payload.txt", "ok")); + + var limits = PackageNormalizeLimits.Default with { MaxEntryCount = 1 }; + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath, limits)); + Assert.Contains("2 ZIP entries", exception.Message); + Assert.Contains("limit of 1", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsEntryThatExceedsPerEntryLimit() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsEntryThatExceedsPerEntryLimit)); + try + { + var packagePath = Path.Combine(projectRoot, "large-entry.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("payload.bin", "123456")); + + var limits = PackageNormalizeLimits.Default with + { + MaxEntryUncompressedBytes = 5, + MaxTotalUncompressedBytes = 100, + }; + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath, limits)); + Assert.Contains("payload.bin", exception.Message); + Assert.Contains("per-entry limit of 5 bytes", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsPackageThatExceedsTotalUncompressedLimit() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsPackageThatExceedsTotalUncompressedLimit)); + try + { + var packagePath = Path.Combine(projectRoot, "large-total.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("a.txt", "1234"), + ("b.txt", "5678")); + + var limits = PackageNormalizeLimits.Default with + { + MaxEntryUncompressedBytes = 10, + MaxTotalUncompressedBytes = 6, + }; + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath, limits)); + Assert.Contains("b.txt", exception.Message); + Assert.Contains("uncompressed size exceed the limit of 6 bytes", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsXmlEntryThatExceedsTextLimit() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsXmlEntryThatExceedsTextLimit)); + try + { + var packagePath = Path.Combine(projectRoot, "large-xml.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("[Content_Types].xml", "123456")); + + var limits = PackageNormalizeLimits.Default with + { + MaxEntryUncompressedBytes = 100, + MaxTotalUncompressedBytes = 100, + MaxXmlTextChars = 5, + }; + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath, limits)); + Assert.Contains("[Content_Types].xml", exception.Message); + Assert.Contains("text limit of 5 characters", exception.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ReleaseWorkflow_PublishesOfficialContainerImage() { @@ -212,6 +325,13 @@ private static void CreateMinimalNuGetPackage(string packagePath, string corePro """); } + private static void CreatePackageWithEntries(string packagePath, params (string EntryName, string Content)[] entries) + { + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + foreach (var entry in entries) + WriteZipEntry(archive, entry.EntryName, entry.Content); + } + private static void WriteZipEntry(ZipArchive archive, string entryName, string content) { var entry = archive.CreateEntry(entryName); diff --git a/tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj b/tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj index 2b8d4aed18..839391dd3c 100644 --- a/tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj +++ b/tools/CodeIndex.PackageNormalize/CodeIndex.PackageNormalize.csproj @@ -8,4 +8,8 @@ false + + + + diff --git a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs index b8dca1d0c0..ad8159511c 100644 --- a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs +++ b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs @@ -30,8 +30,14 @@ public static class PackageCorePropertiesNormalizer private static readonly DateTimeOffset StableZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); public static void NormalizePackage(string packagePath) + { + NormalizePackage(packagePath, PackageNormalizeLimits.Default); + } + + internal static void NormalizePackage(string packagePath, PackageNormalizeLimits limits) { ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); + limits.Validate(); var fullPath = Path.GetFullPath(packagePath); var tempPath = fullPath + ".normalize-tmp"; @@ -40,17 +46,11 @@ public static void NormalizePackage(string packagePath) using (var sourceStream = File.Open(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var sourceArchive = new ZipArchive(sourceStream, ZipArchiveMode.Read, leaveOpen: false)) - using (var destinationStream = File.Open(tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) - using (var destinationArchive = new ZipArchive(destinationStream, ZipArchiveMode.Create, leaveOpen: false)) { - var corePropertiesEntries = sourceArchive.Entries - .Where(entry => IsCorePropertiesPart(entry.FullName)) - .ToArray(); - - if (corePropertiesEntries.Length != 1) - throw new InvalidOperationException($"Expected exactly one NuGet core-properties part in {packagePath}, found {corePropertiesEntries.Length}."); + var originalCorePropertiesPath = ValidateSourceArchive(sourceArchive, packagePath, limits); - var originalCorePropertiesPath = corePropertiesEntries[0].FullName; + using var destinationStream = File.Open(tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + using var destinationArchive = new ZipArchive(destinationStream, ZipArchiveMode.Create, leaveOpen: false); var usedNames = new HashSet(StringComparer.Ordinal); foreach (var sourceEntry in sourceArchive.Entries) @@ -71,13 +71,12 @@ public static void NormalizePackage(string packagePath) if (NeedsXmlReferenceRewrite(sourceEntry.FullName)) { - using var reader = new StreamReader(sourceEntryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false); using var writer = new StreamWriter(destinationEntryStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: false); - writer.Write(RewriteCorePropertiesReferences(reader.ReadToEnd(), originalCorePropertiesPath)); + writer.Write(RewriteCorePropertiesReferences(ReadXmlEntryText(sourceEntry, sourceEntryStream, limits), originalCorePropertiesPath)); } else { - sourceEntryStream.CopyTo(destinationEntryStream); + CopyEntryWithLimit(sourceEntry, sourceEntryStream, destinationEntryStream, limits); } } } @@ -85,6 +84,97 @@ public static void NormalizePackage(string packagePath) File.Move(tempPath, fullPath, overwrite: true); } + private static string ValidateSourceArchive(ZipArchive sourceArchive, string packagePath, PackageNormalizeLimits limits) + { + if (sourceArchive.Entries.Count > limits.MaxEntryCount) + throw new InvalidOperationException($"Package {packagePath} has {sourceArchive.Entries.Count} ZIP entries, which exceeds the limit of {limits.MaxEntryCount}."); + + string? originalCorePropertiesPath = null; + var corePropertiesEntryCount = 0; + long totalUncompressedBytes = 0; + + foreach (var sourceEntry in sourceArchive.Entries) + { + ValidateEntrySize(sourceEntry, limits); + + if (totalUncompressedBytes > limits.MaxTotalUncompressedBytes - sourceEntry.Length) + { + throw new InvalidOperationException( + $"ZIP entry {sourceEntry.FullName} makes package uncompressed size exceed the limit of {limits.MaxTotalUncompressedBytes} bytes."); + } + + totalUncompressedBytes += sourceEntry.Length; + + if (!IsCorePropertiesPart(sourceEntry.FullName)) + continue; + + corePropertiesEntryCount++; + originalCorePropertiesPath = sourceEntry.FullName; + } + + if (corePropertiesEntryCount != 1) + throw new InvalidOperationException($"Expected exactly one NuGet core-properties part in {packagePath}, found {corePropertiesEntryCount}."); + + return originalCorePropertiesPath!; + } + + private static void ValidateEntrySize(ZipArchiveEntry sourceEntry, PackageNormalizeLimits limits) + { + if (sourceEntry.Length > limits.MaxEntryUncompressedBytes) + { + throw new InvalidOperationException( + $"ZIP entry {sourceEntry.FullName} is {sourceEntry.Length} bytes uncompressed, which exceeds the per-entry limit of {limits.MaxEntryUncompressedBytes} bytes."); + } + } + + private static string ReadXmlEntryText(ZipArchiveEntry sourceEntry, Stream sourceEntryStream, PackageNormalizeLimits limits) + { + using var reader = new StreamReader(sourceEntryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false); + var buffer = new char[4096]; + var builder = new StringBuilder(); + + while (true) + { + var charsRead = reader.Read(buffer, 0, buffer.Length); + if (charsRead == 0) + return builder.ToString(); + + if (builder.Length > limits.MaxXmlTextChars - charsRead) + { + throw new InvalidOperationException( + $"XML ZIP entry {sourceEntry.FullName} exceeds the text limit of {limits.MaxXmlTextChars} characters."); + } + + builder.Append(buffer, 0, charsRead); + } + } + + private static void CopyEntryWithLimit( + ZipArchiveEntry sourceEntry, + Stream sourceEntryStream, + Stream destinationEntryStream, + PackageNormalizeLimits limits) + { + var buffer = new byte[81920]; + long bytesCopied = 0; + + while (true) + { + var bytesRead = sourceEntryStream.Read(buffer, 0, buffer.Length); + if (bytesRead == 0) + return; + + if (bytesCopied > limits.MaxEntryUncompressedBytes - bytesRead) + { + throw new InvalidOperationException( + $"ZIP entry {sourceEntry.FullName} exceeds the per-entry copy limit of {limits.MaxEntryUncompressedBytes} bytes."); + } + + bytesCopied += bytesRead; + destinationEntryStream.Write(buffer, 0, bytesRead); + } + } + private static bool IsCorePropertiesPart(string entryName) { return entryName.StartsWith("package/services/metadata/core-properties/", StringComparison.Ordinal) @@ -105,3 +195,31 @@ private static string RewriteCorePropertiesReferences(string content, string ori .Replace("/" + originalCorePropertiesPath, "/" + canonical, StringComparison.Ordinal); } } + +internal readonly record struct PackageNormalizeLimits( + int MaxEntryCount, + long MaxEntryUncompressedBytes, + long MaxTotalUncompressedBytes, + int MaxXmlTextChars) +{ + internal static PackageNormalizeLimits Default { get; } = new( + MaxEntryCount: 4096, + MaxEntryUncompressedBytes: 128L * 1024 * 1024, + MaxTotalUncompressedBytes: 512L * 1024 * 1024, + MaxXmlTextChars: 16 * 1024 * 1024); + + internal void Validate() + { + if (MaxEntryCount <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxEntryCount), MaxEntryCount, "ZIP entry count limit must be positive."); + + if (MaxEntryUncompressedBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxEntryUncompressedBytes), MaxEntryUncompressedBytes, "ZIP entry size limit must be positive."); + + if (MaxTotalUncompressedBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxTotalUncompressedBytes), MaxTotalUncompressedBytes, "ZIP total size limit must be positive."); + + if (MaxXmlTextChars <= 0) + throw new ArgumentOutOfRangeException(nameof(MaxXmlTextChars), MaxXmlTextChars, "ZIP XML text limit must be positive."); + } +} From a248b96ee4c627d4f60f1fc7567382475bd8de1c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 10:21:28 +0900 Subject: [PATCH 2/4] Reject unsafe package ZIP entry names (#2894) --- DEVELOPER_GUIDE.md | 9 +++ changelog.d/unreleased/2894.security.md | 17 +++++ tests/CodeIndex.Tests/ReleaseWorkflowTests.cs | 52 +++++++++++++++ .../PackageNormalizeCli.cs | 65 +++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 changelog.d/unreleased/2894.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 304d7b0688..4d9abfdabf 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -164,6 +164,11 @@ Before rewriting, the normalizer rejects packages with more than 4096 ZIP entries, any entry above 128 MiB uncompressed, total uncompressed content above 512 MiB, or XML reference text above 16 MiB so crafted packages cannot force unbounded normalization work (#2892). +It also rejects unsafe ZIP entry names before creating the destination archive: +absolute paths, Windows drive roots, backslash separators, empty path segments, +parent-directory segments, empty normalized names, and destination names that +collide after path normalization are not preserved into normalized packages +(#2894). When you intentionally update a dependency (or add a new direct `PackageReference`), regenerate the lock files locally and commit the diff in the same change: @@ -2056,6 +2061,10 @@ release の `dotnet publish`(RID ごと)と `dotnet pack`(NuGet パッケ uncompressed entry、512 MiB を超える合計 uncompressed content、または 16 MiB を超える XML 参照テキストを持つ package を拒否し、細工された package が無制限の normalize 作業を強制できないようにします (#2892)。 +また destination archive を作る前に unsafe な ZIP entry 名も拒否します。 +absolute path、Windows drive root、backslash separator、空の path segment、 +parent-directory segment、空に正規化される名前、path 正規化後に衝突する +destination 名は、normalized package に保持されません (#2894)。 依存を意図的に更新する(あるいは直接 `PackageReference` を追加する)場合は、ローカルで lock ファイルを再生成し、同じ変更でコミットしてください: diff --git a/changelog.d/unreleased/2894.security.md b/changelog.d/unreleased/2894.security.md new file mode 100644 index 0000000000..1712b2b6e7 --- /dev/null +++ b/changelog.d/unreleased/2894.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2894 +affected: + - tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Package normalization now rejects unsafe ZIP entry names (#2894)** — `CodeIndex.PackageNormalize` validates source and destination ZIP entry names before rewriting, preventing absolute paths, parent traversal, backslash separators, empty path segments, and normalized duplicate destination names from being preserved. + +## 日本語 + +- **Package normalize が unsafe な ZIP entry 名を拒否するようになりました (#2894)** — `CodeIndex.PackageNormalize` は書き換え前に source / destination の ZIP entry 名を検証し、absolute path、parent traversal、backslash separator、空の path segment、正規化後に重複する destination 名が保持されないようにします。 diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index c95bf5e316..bdc6480d99 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -259,6 +259,58 @@ public void PackageNormalizer_RejectsXmlEntryThatExceedsTextLimit() } } + [Theory] + [InlineData("/payload.txt", "must be a relative path")] + [InlineData("C:/payload.txt", "must be a relative path")] + [InlineData("../payload.txt", "must not contain parent-directory segments")] + [InlineData("folder\\payload.txt", "must use '/' separators")] + [InlineData("folder//payload.txt", "must not contain empty path segments")] + public void PackageNormalizer_RejectsUnsafeZipEntryNames(string unsafeEntryName, string expectedMessage) + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsUnsafeZipEntryNames)); + try + { + var packagePath = Path.Combine(projectRoot, "unsafe-name.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + (unsafeEntryName, "payload")); + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath)); + Assert.Contains(unsafeEntryName, exception.Message); + Assert.Contains(expectedMessage, exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizer_RejectsDestinationNamesThatNormalizeToDuplicates() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizer_RejectsDestinationNamesThatNormalizeToDuplicates)); + try + { + var packagePath = Path.Combine(projectRoot, "duplicate-normalized-name.nupkg"); + CreatePackageWithEntries( + packagePath, + ("package/services/metadata/core-properties/random.psmdcp", ""), + ("docs/readme.txt", "one"), + ("docs/./readme.txt", "two")); + + var exception = Assert.Throws(() => PackageCorePropertiesNormalizer.NormalizePackage(packagePath)); + Assert.Contains("docs/./readme.txt", exception.Message); + Assert.Contains("duplicate destination name docs/readme.txt", exception.Message); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ReleaseWorkflow_PublishesOfficialContainerImage() { diff --git a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs index ad8159511c..dd322412f9 100644 --- a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs +++ b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs @@ -48,6 +48,7 @@ internal static void NormalizePackage(string packagePath, PackageNormalizeLimits using (var sourceArchive = new ZipArchive(sourceStream, ZipArchiveMode.Read, leaveOpen: false)) { var originalCorePropertiesPath = ValidateSourceArchive(sourceArchive, packagePath, limits); + ValidateEntryNamesBeforeRewrite(sourceArchive, originalCorePropertiesPath); using var destinationStream = File.Open(tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); using var destinationArchive = new ZipArchive(destinationStream, ZipArchiveMode.Create, leaveOpen: false); @@ -118,6 +119,70 @@ private static string ValidateSourceArchive(ZipArchive sourceArchive, string pac return originalCorePropertiesPath!; } + private static void ValidateEntryNamesBeforeRewrite(ZipArchive sourceArchive, string originalCorePropertiesPath) + { + var normalizedDestinationNames = new HashSet(StringComparer.Ordinal); + + foreach (var sourceEntry in sourceArchive.Entries) + { + ValidateZipEntryName(sourceEntry.FullName, "source"); + + var destinationName = sourceEntry.FullName == originalCorePropertiesPath + ? CanonicalCorePropertiesPath + : sourceEntry.FullName; + var normalizedDestinationName = ValidateZipEntryName(destinationName, "destination"); + + if (!normalizedDestinationNames.Add(normalizedDestinationName)) + { + throw new InvalidOperationException( + $"ZIP entry {destinationName} normalizes to duplicate destination name {normalizedDestinationName}."); + } + } + } + + private static string ValidateZipEntryName(string entryName, string role) + { + if (entryName.Length == 0) + throw new InvalidOperationException($"ZIP {role} entry name must not be empty."); + + if (entryName.Contains('\\')) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must use '/' separators, not backslashes."); + + if (entryName.Contains('\0')) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must not contain NUL characters."); + + if (entryName[0] == '/' || StartsWithWindowsDrivePrefix(entryName)) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must be a relative path."); + + var segments = entryName.Split('/'); + var normalizedSegments = new List(segments.Length); + foreach (var segment in segments) + { + if (segment.Length == 0) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must not contain empty path segments."); + + if (segment == "..") + throw new InvalidOperationException($"ZIP {role} entry {entryName} must not contain parent-directory segments."); + + if (segment == ".") + continue; + + normalizedSegments.Add(segment); + } + + if (normalizedSegments.Count == 0) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must not normalize to an empty path."); + + return string.Join('/', normalizedSegments); + } + + private static bool StartsWithWindowsDrivePrefix(string entryName) + { + return entryName.Length >= 2 + && entryName[1] == ':' + && ((entryName[0] >= 'A' && entryName[0] <= 'Z') || (entryName[0] >= 'a' && entryName[0] <= 'z')); + } + private static void ValidateEntrySize(ZipArchiveEntry sourceEntry, PackageNormalizeLimits limits) { if (sourceEntry.Length > limits.MaxEntryUncompressedBytes) From c20a2c90890a2b575c4969748baa1de1548d641c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 11:14:17 +0900 Subject: [PATCH 3/4] Enforce package normalization inflated byte budget (#2892) --- .../PackageNormalizeCli.cs | 122 ++++++++++++++++-- 1 file changed, 110 insertions(+), 12 deletions(-) diff --git a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs index dd322412f9..f642f96f72 100644 --- a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs +++ b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs @@ -52,6 +52,7 @@ internal static void NormalizePackage(string packagePath, PackageNormalizeLimits using var destinationStream = File.Open(tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); using var destinationArchive = new ZipArchive(destinationStream, ZipArchiveMode.Create, leaveOpen: false); + var readBudget = new PackageNormalizeReadBudget(limits); var usedNames = new HashSet(StringComparer.Ordinal); foreach (var sourceEntry in sourceArchive.Entries) @@ -67,7 +68,8 @@ internal static void NormalizePackage(string packagePath, PackageNormalizeLimits destinationEntry.LastWriteTime = StableZipTimestamp; destinationEntry.ExternalAttributes = sourceEntry.ExternalAttributes; - using var sourceEntryStream = sourceEntry.Open(); + using var rawSourceEntryStream = sourceEntry.Open(); + using var sourceEntryStream = new BudgetedEntryReadStream(rawSourceEntryStream, sourceEntry, readBudget); using var destinationEntryStream = destinationEntry.Open(); if (NeedsXmlReferenceRewrite(sourceEntry.FullName)) @@ -77,7 +79,7 @@ internal static void NormalizePackage(string packagePath, PackageNormalizeLimits } else { - CopyEntryWithLimit(sourceEntry, sourceEntryStream, destinationEntryStream, limits); + CopyEntry(sourceEntryStream, destinationEntryStream); } } } @@ -214,14 +216,9 @@ private static string ReadXmlEntryText(ZipArchiveEntry sourceEntry, Stream sourc } } - private static void CopyEntryWithLimit( - ZipArchiveEntry sourceEntry, - Stream sourceEntryStream, - Stream destinationEntryStream, - PackageNormalizeLimits limits) + private static void CopyEntry(Stream sourceEntryStream, Stream destinationEntryStream) { var buffer = new byte[81920]; - long bytesCopied = 0; while (true) { @@ -229,14 +226,115 @@ private static void CopyEntryWithLimit( if (bytesRead == 0) return; - if (bytesCopied > limits.MaxEntryUncompressedBytes - bytesRead) + destinationEntryStream.Write(buffer, 0, bytesRead); + } + } + + private sealed class PackageNormalizeReadBudget + { + private readonly PackageNormalizeLimits _limits; + private long _totalBytesRead; + + internal PackageNormalizeReadBudget(PackageNormalizeLimits limits) + { + _limits = limits; + } + + internal void AddBytes(ZipArchiveEntry sourceEntry, long entryBytesRead, int bytesRead) + { + if (bytesRead <= 0) + return; + + if (entryBytesRead > _limits.MaxEntryUncompressedBytes - bytesRead) { throw new InvalidOperationException( - $"ZIP entry {sourceEntry.FullName} exceeds the per-entry copy limit of {limits.MaxEntryUncompressedBytes} bytes."); + $"ZIP entry {sourceEntry.FullName} exceeds the per-entry inflated size limit of {_limits.MaxEntryUncompressedBytes} bytes."); } - bytesCopied += bytesRead; - destinationEntryStream.Write(buffer, 0, bytesRead); + if (_totalBytesRead > _limits.MaxTotalUncompressedBytes - bytesRead) + { + throw new InvalidOperationException( + $"ZIP entry {sourceEntry.FullName} makes actual inflated package size exceed the limit of {_limits.MaxTotalUncompressedBytes} bytes."); + } + + _totalBytesRead += bytesRead; + } + } + + private sealed class BudgetedEntryReadStream : Stream + { + private readonly Stream _inner; + private readonly ZipArchiveEntry _sourceEntry; + private readonly PackageNormalizeReadBudget _readBudget; + private long _entryBytesRead; + + internal BudgetedEntryReadStream(Stream inner, ZipArchiveEntry sourceEntry, PackageNormalizeReadBudget readBudget) + { + _inner = inner; + _sourceEntry = sourceEntry; + _readBudget = readBudget; + } + + public override bool CanRead => _inner.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesRead = _inner.Read(buffer, offset, count); + TrackBytesRead(bytesRead); + return bytesRead; + } + + public override int Read(Span buffer) + { + var bytesRead = _inner.Read(buffer); + TrackBytesRead(bytesRead); + return bytesRead; + } + + public override void Flush() + { + throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _inner.Dispose(); + + base.Dispose(disposing); + } + + private void TrackBytesRead(int bytesRead) + { + _readBudget.AddBytes(_sourceEntry, _entryBytesRead, bytesRead); + _entryBytesRead += bytesRead; } } From 7bdc0ef0344670c366be1ac5288ec361115cbc05 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 11:14:36 +0900 Subject: [PATCH 4/4] Reject dot-normalized ZIP drive roots (#2894) --- tests/CodeIndex.Tests/ReleaseWorkflowTests.cs | 1 + tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index bdc6480d99..189d088491 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -262,6 +262,7 @@ public void PackageNormalizer_RejectsXmlEntryThatExceedsTextLimit() [Theory] [InlineData("/payload.txt", "must be a relative path")] [InlineData("C:/payload.txt", "must be a relative path")] + [InlineData("./C:/payload.txt", "must be a relative path")] [InlineData("../payload.txt", "must not contain parent-directory segments")] [InlineData("folder\\payload.txt", "must use '/' separators")] [InlineData("folder//payload.txt", "must not contain empty path segments")] diff --git a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs index f642f96f72..d4398e550c 100644 --- a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs +++ b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs @@ -175,7 +175,11 @@ private static string ValidateZipEntryName(string entryName, string role) if (normalizedSegments.Count == 0) throw new InvalidOperationException($"ZIP {role} entry {entryName} must not normalize to an empty path."); - return string.Join('/', normalizedSegments); + var normalizedName = string.Join('/', normalizedSegments); + if (normalizedName[0] == '/' || StartsWithWindowsDrivePrefix(normalizedName)) + throw new InvalidOperationException($"ZIP {role} entry {entryName} must be a relative path."); + + return normalizedName; } private static bool StartsWithWindowsDrivePrefix(string entryName)