diff --git a/src/libraries/Common/src/System/IO/SubReadStream.cs b/src/libraries/Common/src/System/IO/SubReadStream.cs index f5efbedf8066da..7bfcc8738d55a0 100644 --- a/src/libraries/Common/src/System/IO/SubReadStream.cs +++ b/src/libraries/Common/src/System/IO/SubReadStream.cs @@ -50,6 +50,27 @@ public override long Length } } + // Returns the number of bytes actually available for this stream's window in the super stream, + // i.e. how many bytes remain in the super stream, starting from this window's start position, + // before hitting the actual end of the super stream. Returns null when the super stream is not + // seekable, since its total length cannot be determined without consuming it. + // This can be smaller than Length when the window's declared length extends past the actual data + // available in the super stream (for example, a corrupted or maliciously crafted length field). + internal long? AvailableLengthInSuperStream + { + get + { + ThrowIfDisposed(); + if (!_superStream.CanSeek) + { + return null; + } + + long superStreamLength = _superStream.Length; + return superStreamLength <= _startInSuperStream ? 0 : superStreamLength - _startInSuperStream; + } + } + public override long Position { get diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs index 71035c6a2d440e..48ee2f2f6c2060 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs @@ -739,7 +739,7 @@ private FileStreamOptions CreateFileStreamOptions(bool isAsync) // (real) size, while the archive only contains the much smaller packed data. // Preallocating to the expanded size would reserve disk space that bears no // relation to the archive contents and can fail surprisingly on small volumes. - PreallocationSize = _header._gnuSparseDataStream is null ? Length : 0, + PreallocationSize = _header._gnuSparseDataStream is null ? GetPreallocationSize() : 0, Options = isAsync ? FileOptions.Asynchronous : FileOptions.None }; @@ -757,5 +757,39 @@ private FileStreamOptions CreateFileStreamOptions(bool isAsync) return fileStreamOptions; } + + // Determines the number of bytes to preallocate for the destination file. + // The entry's declared Length comes directly from the (potentially attacker-controlled) tar + // header's size field, so when the data section is backed by a SubReadStream over the archive + // stream, cap the preallocation to the number of bytes actually remaining in the archive stream. + // This prevents a crafted entry that declares a huge size but provides little or no actual data + // from causing an excessive up-front file preallocation (which can exhaust disk space or hang). + // Since extraction writes from the stream's current position, the remaining declared bytes + // (rather than the full Length) are compared against the remaining available archive data. + // When the archive stream isn't seekable, its true remaining length can't be determined without + // consuming it, so preallocation is skipped entirely rather than trusting the declared size. + // Non-SubReadStream data sources (e.g. a user-provided DataStream) are preallocated using the + // full remaining reported Length, since there is no archive stream to validate against. + private long GetPreallocationSize() + { + long length = Length; + + if (length > 0 && _header._dataStream is SubReadStream subReadStream) + { + long remainingDeclared = length - subReadStream.Position; + + long? availableLength = subReadStream.AvailableLengthInSuperStream; + if (!availableLength.HasValue) + { + // Unseekable archive stream: the declared size cannot be validated against the + // actual remaining data, so don't preallocate based on it at all. + return 0; + } + + return Math.Min(remainingDeclared, availableLength.Value); + } + + return length; + } } } diff --git a/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntry.ExtractToFile.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntry.ExtractToFile.Tests.cs index 3cfcf4e6e36ca7..fc4a04674c9522 100644 --- a/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntry.ExtractToFile.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntry.ExtractToFile.Tests.cs @@ -11,6 +11,98 @@ namespace System.Formats.Tar.Tests { public class TarEntry_ExtractToFile_Tests : TarTestsBase { + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ExtractToFile_MismatchedSizeField_DoesNotPreallocateDeclaredSize(bool seekableArchive) + { + // Craft an entry whose header declares a size much larger than the archive + // actually contains. If the declared size were used verbatim for preallocation, + // extraction could attempt to reserve an arbitrarily large amount of disk space + // (disk-exhaustion) even though almost no real data backs it. Instead, extraction + // should only preallocate/write as much data as is actually available, producing + // a truncated file rather than reserving the bogus declared size. + // Covers both a seekable archive stream (where the real remaining length can be + // determined) and an unseekable one (where preallocation must be skipped entirely). + const long HugeDeclaredSize = 500_000_000; // 500 MB declared, far exceeding the tiny real archive. + byte[] actualData = "small"u8.ToArray(); + + byte[] archive = BuildRawPaxArchiveWithSizeOverride("file.bin", "file.bin", actualData, HugeDeclaredSize, HugeDeclaredSize); + + long apiLength; + using (var scanStream = new MemoryStream(archive)) + using (var reader = new TarReader(scanStream)) + { + TarEntry entry = reader.GetNextEntry(copyData: false); + Assert.NotNull(entry); + apiLength = entry.Length; + Assert.Equal(HugeDeclaredSize, apiLength); + } + + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "file.bin"); + + Stream extractStream = seekableArchive + ? new MemoryStream(archive) + : new WrappedStream(new MemoryStream(archive), canRead: true, canWrite: false, canSeek: false); + using (extractStream) + using (var extractReader = new TarReader(extractStream)) + { + TarEntry entryToExtract = extractReader.GetNextEntry(copyData: false); + Assert.NotNull(entryToExtract); + + // Should complete without hanging or exhausting disk trying to preallocate 500 MB + // for a few bytes of real data; the resulting file is truncated to the real data available. + entryToExtract.ExtractToFile(destination, overwrite: false); + } + + long extractedSize = new FileInfo(destination).Length; + Assert.True(extractedSize < HugeDeclaredSize); + } + + [Theory] + [InlineData(10, 100_000, true)] // declared size far larger than the entire remaining archive: extraction is bounded by available data, not the bogus declared size + [InlineData(100, 25, true)] // declared size smaller than actual data: gets truncated to the declared size + [InlineData(10, 100_000, false)] // same as above, but the archive stream is unseekable + [InlineData(100, 25, false)] + public void ExtractToFile_MismatchedSizeField_MatchesAvailableData(int dataSize, long headerSizeField, bool seekableArchive) + { + byte[] actualData = new byte[dataSize]; + Array.Fill(actualData, (byte)'X'); + + byte[] archive = BuildRawPaxArchiveWithSizeOverride("file.bin", "file.bin", actualData, headerSizeField, headerSizeField); + + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "file.bin"); + + Stream extractStream = seekableArchive + ? new MemoryStream(archive) + : new WrappedStream(new MemoryStream(archive), canRead: true, canWrite: false, canSeek: false); + using (extractStream) + using (var reader = new TarReader(extractStream)) + { + TarEntry entry = reader.GetNextEntry(copyData: false); + Assert.NotNull(entry); + + entry.ExtractToFile(destination, overwrite: false); + } + + long extractedSize = new FileInfo(destination).Length; + + if (headerSizeField <= dataSize) + { + Assert.Equal(headerSizeField, extractedSize); + } + else + { + // Not enough real data to satisfy the declared size: extraction stops once the + // underlying archive stream is exhausted, and never reaches the huge declared size. + Assert.True(extractedSize < headerSizeField); + Assert.True(extractedSize <= archive.Length); + } + } + + [Theory] [InlineData(TarEntryFormat.V7)] [InlineData(TarEntryFormat.Ustar)]